From ff5cdf47df876f106aef93ab95ce8e87fba6f7e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Steinhagen?= Date: Tue, 2 Sep 2025 11:39:36 +0200 Subject: [PATCH] feat(etabs): add result extraction with UI integration (#1044) * feat: poc hack - just send some results as Base to serve as a discussion point * refactor: column forces extraction class * feat: column forces compound keys * feat: basic check if results available * Revert "Merge remote-tracking branch 'origin/dev' into bjorn/properties-curation-structural-connectors-analysis-results" This reverts commit 4b88fc150f7bba46b739851145c34070f2c03da5, reversing changes made to 855240b71321a1426e20723f475d326f58ccc89a. * Reapply "Merge remote-tracking branch 'origin/dev' into bjorn/properties-curation-structural-connectors-analysis-results" This reverts commit 57f66dea7bbb9154479fcf8df99f80b3d68be13f. * feat (etabs): multi-selectable dropdowns for analysis result (#1019) * integrated ui components * populates the dropdown * format * removed filtering logic * feat(etabs): replace database table extraction with direct Results API for analysis results (#1024) * feat: first steps in linking ui to results extractor * refactor: simple frame force extractor * refactor: flexible extractor * chore: cleanup * refactor: computed property * feat(etabs): add UI integration for dynamic result type selection (#1025) * refactor: linking up results type * fix: send settings * feat(etabs): adds more extractors (#1026) * feat: adds `BaseReact` extractor * refactor: repeating strings under constants * fix: array processing only * feat: adds `PierForce`extractor * feat: adds `SpandrelForce` extractor * feat: adds `StoryDrifts` extractor * fix: missing key in selection shouldn't throw * feat: adds `JointReact` extractor * refactor(etabs): improve load case validation and error handling * fix: case status validation * fix(etabs): correct Zip method syntax for load case validation * refactor(etabs): simplify validation by throwing `SpeckleException`inside `LoadCaseManager` * refactor: add unit information --------- Co-authored-by: Dogukan Karatas <61163577+dogukankaratas@users.noreply.github.com> Co-authored-by: Claire Kuang --- .../Bindings/CsiSharedSelectionBinding.cs | 2 +- .../Bindings/CsiSharedSendBinding.cs | 22 ++- .../Helpers/CsiResultsExtractorFactory.cs | 27 +++ .../Operations/Send/CsiRootObjectBuilder.cs | 53 ++++- .../Send/Settings/ToSpeckleSettingsManager.cs | 73 +++++++ .../ServiceRegistration.cs | 7 + .../Settings/LoadCaseCombinationSetting.cs | 13 ++ .../Settings/ResultTypeSetting.cs | 13 ++ .../Speckle.Connectors.CSiShared.projitems | 6 + .../Utils/AnalysisResultsExtractor.cs | 184 ++++++++++++++++++ .../Utils/LoadCaseHelper.cs | 56 ++++++ .../CsiConversionSettings.cs | 7 +- .../CsiConversionSettingsFactory.cs | 13 +- .../CsiRootToSpeckleConverter.cs | 11 +- .../Extensions/DatabaseTableExtensions.cs | 102 ++++++++-- .../ServiceRegistration.cs | 9 + .../Speckle.Converters.CSiShared.projitems | 9 + .../Helpers/CsiBaseReactResultsExtractor.cs | 89 +++++++++ .../Helpers/CsiFrameForceResultsExtractor.cs | 112 +++++++++++ .../Helpers/CsiFramePropertiesExtractor.cs | 1 - .../Helpers/CsiJointReactResultsExtractor.cs | 115 +++++++++++ .../Helpers/CsiPierForceResultsExtractor.cs | 78 ++++++++ .../CsiSpandrelForceResultsExtractor.cs | 79 ++++++++ .../Helpers/CsiStoryDriftsResultsExtractor.cs | 78 ++++++++ .../Helpers/DatabaseTableExtractor.cs | 46 +++-- .../Helpers/IApplicationResultsExtractor.cs | 36 ++++ .../Helpers/ResultsArrayProcessor.cs | 98 ++++++++++ .../Utils/Constants.cs | 24 +++ .../Utils/ResultsConfiguration.cs | 8 + 29 files changed, 1320 insertions(+), 51 deletions(-) create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/HostApp/Helpers/CsiResultsExtractorFactory.cs create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/Settings/ToSpeckleSettingsManager.cs create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/Settings/LoadCaseCombinationSetting.cs create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/Settings/ResultTypeSetting.cs create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/Utils/AnalysisResultsExtractor.cs create mode 100644 Connectors/CSi/Speckle.Connectors.CSiShared/Utils/LoadCaseHelper.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiBaseReactResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFrameForceResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiJointReactResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiPierForceResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiSpandrelForceResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiStoryDriftsResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/IApplicationResultsExtractor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/ResultsArrayProcessor.cs create mode 100644 Converters/CSi/Speckle.Converters.CSiShared/Utils/ResultsConfiguration.cs diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSelectionBinding.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSelectionBinding.cs index bb3592e44..7aabc0951 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSelectionBinding.cs +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSelectionBinding.cs @@ -96,7 +96,7 @@ public class CsiSharedSelectionBinding : ISelectionBinding, IDisposable var typeKey = (ModelObjectType)objectType[i]; var typeName = typeKey.ToString(); encodedIds.Add(ObjectIdentifier.Encode(objectType[i], objectName[i])); - typeCounts[typeName] = (typeCounts.TryGetValue(typeName, out var count) ? count : 0) + 1; // NOTE: Cross-framework compatibility (net 48 and net8) + typeCounts[typeName] = (typeCounts.TryGetValue(typeName, out var count) ? count : 0) + 1; } var summary = encodedIds.Count == 0 diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSendBinding.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSendBinding.cs index 62aa2a2d0..a56e85874 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSendBinding.cs +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Bindings/CsiSharedSendBinding.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Speckle.Connectors.Common.Cancellation; using Speckle.Connectors.CSiShared.HostApp; +using Speckle.Connectors.CSiShared.Operations.Send.Settings; +using Speckle.Connectors.CSiShared.Settings; using Speckle.Connectors.CSiShared.Utils; using Speckle.Connectors.DUI.Bindings; using Speckle.Connectors.DUI.Bridge; @@ -23,6 +25,7 @@ public sealed class CsiSharedSendBinding : ISendBinding private readonly ICsiApplicationService _csiApplicationService; private readonly ICsiConversionSettingsFactory _csiConversionSettingsFactory; private readonly ISendOperationManagerFactory _sendOperationManagerFactory; + private readonly ToSpeckleSettingsManager _toSpeckleSettingsManager; public CsiSharedSendBinding( IBrowserBridge parent, @@ -30,7 +33,8 @@ public sealed class CsiSharedSendBinding : ISendBinding ICancellationManager cancellationManager, ICsiConversionSettingsFactory csiConversionSettingsFactory, ICsiApplicationService csiApplicationService, - ISendOperationManagerFactory sendOperationManagerFactory + ISendOperationManagerFactory sendOperationManagerFactory, + ToSpeckleSettingsManager toSpeckleSettingsManager ) { _sendFilters = sendFilters.ToList(); @@ -40,11 +44,13 @@ public sealed class CsiSharedSendBinding : ISendBinding _csiConversionSettingsFactory = csiConversionSettingsFactory; _csiApplicationService = csiApplicationService; _sendOperationManagerFactory = sendOperationManagerFactory; + _toSpeckleSettingsManager = toSpeckleSettingsManager; } public List GetSendFilters() => _sendFilters; - public List GetSendSettings() => []; + public List GetSendSettings() => + [new LoadCaseCombinationSetting([], _csiApplicationService.SapModel), new ResultTypeSetting([])]; public async Task Send(string modelCardId) { @@ -52,9 +58,17 @@ public sealed class CsiSharedSendBinding : ISendBinding await manager.Process( Commands, modelCardId, - (sp, _) => + (sp, card) => + { sp.GetRequiredService>() - .Initialize(_csiConversionSettingsFactory.Create(_csiApplicationService.SapModel)), + .Initialize( + _csiConversionSettingsFactory.Create( + _csiApplicationService.SapModel, + _toSpeckleSettingsManager.GetLoadCasesAndCombinations(card), + _toSpeckleSettingsManager.GetResultTypes(card) + ) + ); + }, card => card.SendFilter.NotNull().RefreshObjectIds().Select(DecodeObjectIdentifier).ToList() ); } diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/HostApp/Helpers/CsiResultsExtractorFactory.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/HostApp/Helpers/CsiResultsExtractorFactory.cs new file mode 100644 index 000000000..9faa690f9 --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/HostApp/Helpers/CsiResultsExtractorFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Speckle.Converters.CSiShared.ToSpeckle.Helpers; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Connectors.CSiShared.HostApp.Helpers; + +public class CsiResultsExtractorFactory +{ + private readonly IServiceProvider _serviceProvider; + + public CsiResultsExtractorFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IApplicationResultsExtractor GetExtractor(string resultsKey) => + resultsKey switch + { + ResultsKey.BASE_REACT => _serviceProvider.GetRequiredService(), + ResultsKey.FRAME_FORCES => _serviceProvider.GetRequiredService(), + ResultsKey.JOINT_REACT => _serviceProvider.GetRequiredService(), + ResultsKey.PIER_FORCES => _serviceProvider.GetRequiredService(), + ResultsKey.SPANDREL_FORCES => _serviceProvider.GetRequiredService(), + ResultsKey.STORY_DRIFTS => _serviceProvider.GetRequiredService(), + _ => throw new InvalidOperationException($"{resultsKey} not accounted for in CsiResultsExtractorFactory") + }; +} diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/CsiRootObjectBuilder.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/CsiRootObjectBuilder.cs index 3b16da9c6..0b7cfc39c 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/CsiRootObjectBuilder.cs +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/CsiRootObjectBuilder.cs @@ -4,9 +4,11 @@ using Speckle.Connectors.Common.Conversion; using Speckle.Connectors.Common.Operations; using Speckle.Connectors.CSiShared.HostApp; using Speckle.Connectors.CSiShared.HostApp.Helpers; +using Speckle.Connectors.CSiShared.Utils; using Speckle.Converters.Common; using Speckle.Converters.CSiShared; using Speckle.Converters.CSiShared.Extensions; +using Speckle.Converters.CSiShared.Utils; using Speckle.Sdk; using Speckle.Sdk.Logging; using Speckle.Sdk.Models; @@ -37,6 +39,7 @@ public class CsiRootObjectBuilder : IRootObjectBuilder private readonly ILogger _logger; private readonly ISdkActivityFactory _activityFactory; private readonly ICsiApplicationService _csiApplicationService; + private readonly AnalysisResultsExtractor _analysisResultsExtractor; public CsiRootObjectBuilder( IRootToSpeckleConverter rootToSpeckleConverter, @@ -46,7 +49,8 @@ public class CsiRootObjectBuilder : IRootObjectBuilder ISectionUnpacker sectionUnpacker, ILogger logger, ISdkActivityFactory activityFactory, - ICsiApplicationService csiApplicationService + ICsiApplicationService csiApplicationService, + AnalysisResultsExtractor analysisResultsExtractor ) { _converterSettings = converterSettings; @@ -57,6 +61,7 @@ public class CsiRootObjectBuilder : IRootObjectBuilder _logger = logger; _activityFactory = activityFactory; _csiApplicationService = csiApplicationService; + _analysisResultsExtractor = analysisResultsExtractor; } /// @@ -112,6 +117,34 @@ public class CsiRootObjectBuilder : IRootObjectBuilder rootObjectCollection[ProxyKeys.SECTION] = _sectionUnpacker.UnpackSections().ToList(); } + // Extract analysis results (if applicable) + // NOTE: objectSelectionSummary used to extract results for objects being published ONLY + // NOTE: etabs is complicated and we can't get specifics from original selection + var objectSelectionSummary = GetObjectSummary(csiObjects); + var selectedCasesAndCombinations = _converterSettings.Current.SelectedLoadCasesAndCombinations; + var requestedResultTypes = _converterSettings.Current.SelectedResultTypes; + + if (selectedCasesAndCombinations?.Count > 0) + { + if (requestedResultTypes == null || requestedResultTypes.Count == 0) + { + throw new SpeckleException( + "No result type input for the requested load cases and combinations. Adjust publish settings." + ); + } + + if (!_csiApplicationService.SapModel.GetModelIsLocked()) + { + throw new SpeckleException("Model unlocked. No access to analysis results."); + } + + var analysisResults = _analysisResultsExtractor.ExtractAnalysisResults( + selectedCasesAndCombinations, + requestedResultTypes, + objectSelectionSummary + ); + rootObjectCollection["analysisResults"] = analysisResults; + } return new RootObjectBuilderResult(rootObjectCollection, results); } @@ -158,4 +191,22 @@ public class CsiRootObjectBuilder : IRootObjectBuilder return new(Status.ERROR, applicationId, sourceType, null, ex); } } + + /// + /// Generates a summary of object types and their associated names from the collection of CSI wrappers. + /// + /// + /// A summary of object names for each object type is needed for getting analysis results of the selected objects only. + /// During object conversion, however, we lose the selection (like a clear selection)(presumably because of other api calls). + /// This has to be recreated since GetSelection() return type is bound by the interface. + /// The LINQ-based implementation is computationally inexpensive as it operates on an already-loaded collection without additional API calls. + /// Also, we don't want to rely on user selection remaining active, what if someone re-publishes using model card cache? + /// + private Dictionary> GetObjectSummary(IReadOnlyList csiObjects) => + csiObjects + .GroupBy(csiObject => csiObject.ObjectType) + .ToDictionary( + group => group.Key, // ModelObjectType (FRAME, JOINT, etc.) + group => group.Select(obj => obj.Name).ToList() // Extract Name from each ICsiWrapper and convert to List + ); } diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/Settings/ToSpeckleSettingsManager.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/Settings/ToSpeckleSettingsManager.cs new file mode 100644 index 000000000..5a6230b29 --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Operations/Send/Settings/ToSpeckleSettingsManager.cs @@ -0,0 +1,73 @@ +using Speckle.Connectors.Common.Caching; +using Speckle.Connectors.DUI.Models.Card; +using Speckle.InterfaceGenerator; +using Speckle.Newtonsoft.Json.Linq; +using Speckle.Sdk.Common; + +namespace Speckle.Connectors.CSiShared.Operations.Send.Settings; + +[GenerateAutoInterface] +public class ToSpeckleSettingsManager : IToSpeckleSettingsManager +{ + private readonly ISendConversionCache _sendConversionCache; + private readonly Dictionary?> _loadCaseCombinationCache = new(); + private readonly Dictionary?> _resultTypeCache = new(); + + public ToSpeckleSettingsManager(ISendConversionCache sendConversionCache) + { + _sendConversionCache = sendConversionCache; + } + + public List GetLoadCasesAndCombinations(SenderModelCard modelCard) + { + var setting = modelCard.Settings?.FirstOrDefault(s => s.Id == "loadCasesAndCombinations"); + var returnValue = (setting?.Value as JArray)?.Select(x => x.ToString()).ToList() ?? []; + + if (_loadCaseCombinationCache.TryGetValue(modelCard.ModelCardId.NotNull(), out List? previousValue)) + { + if (!AreListsEqual(previousValue, returnValue)) + { + EvictCacheForModelCard(modelCard); + } + } + _loadCaseCombinationCache[modelCard.ModelCardId] = returnValue; + return returnValue; + } + + public List GetResultTypes(SenderModelCard modelCard) + { + var setting = modelCard.Settings?.FirstOrDefault(s => s.Id == "resultTypes"); + var returnValue = (setting?.Value as JArray)?.Select(x => x.ToString()).ToList() ?? []; + + if (_resultTypeCache.TryGetValue(modelCard.ModelCardId.NotNull(), out List? previousValue)) + { + if (!AreListsEqual(previousValue, returnValue)) + { + EvictCacheForModelCard(modelCard); + } + } + _resultTypeCache[modelCard.ModelCardId] = returnValue; + return returnValue; + } + + private static bool AreListsEqual(List? list1, List? list2) + { + if (list1 == null && list2 == null) + { + return true; + } + + if (list1 == null || list2 == null) + { + return false; + } + + return list1.Count == list2.Count && list1.OrderBy(x => x).SequenceEqual(list2.OrderBy(x => x)); + } + + private void EvictCacheForModelCard(SenderModelCard modelCard) + { + var objectIds = modelCard.SendFilter != null ? modelCard.SendFilter.NotNull().RefreshObjectIds() : []; + _sendConversionCache.EvictObjects(objectIds); + } +} diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/ServiceRegistration.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/ServiceRegistration.cs index 6d7ce7487..7d78a2aad 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/ServiceRegistration.cs +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/ServiceRegistration.cs @@ -8,6 +8,8 @@ using Speckle.Connectors.CSiShared.Builders; using Speckle.Connectors.CSiShared.Filters; using Speckle.Connectors.CSiShared.HostApp; using Speckle.Connectors.CSiShared.HostApp.Helpers; +using Speckle.Connectors.CSiShared.Operations.Send.Settings; +using Speckle.Connectors.CSiShared.Utils; using Speckle.Connectors.DUI; using Speckle.Connectors.DUI.Bindings; using Speckle.Connectors.DUI.Bridge; @@ -44,13 +46,18 @@ public static class ServiceRegistration services.AddScoped>(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // add converter caches services.AddScoped(); + // add settings manager + services.AddScoped(); + return services; } } diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/LoadCaseCombinationSetting.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/LoadCaseCombinationSetting.cs new file mode 100644 index 000000000..96601f099 --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/LoadCaseCombinationSetting.cs @@ -0,0 +1,13 @@ +using Speckle.Connectors.CSiShared.Utils; +using Speckle.Connectors.DUI.Settings; + +namespace Speckle.Connectors.CSiShared.Settings; + +public class LoadCaseCombinationSetting(List values, cSapModel sapModel) : ICardSetting +{ + public string? Id { get; set; } = "loadCasesAndCombinations"; + public string? Title { get; set; } = "Load Cases & Combinations"; + public string? Type { get; set; } = "array"; + public object? Value { get; set; } = values; + public List? Enum { get; set; } = LoadCaseHelper.GetLoadCasesAndCombinations(sapModel); +} diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/ResultTypeSetting.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/ResultTypeSetting.cs new file mode 100644 index 000000000..826324a3b --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Settings/ResultTypeSetting.cs @@ -0,0 +1,13 @@ +using Speckle.Connectors.DUI.Settings; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Connectors.CSiShared.Settings; + +public class ResultTypeSetting(List values) : ICardSetting +{ + public string? Id { get; set; } = "resultTypes"; + public string? Title { get; set; } = "Result Type"; + public string? Type { get; set; } = "array"; + public object? Value { get; set; } = values; + public List? Enum { get; set; } = ResultsKey.All.OrderBy(x => x).ToList(); +} diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Speckle.Connectors.CSiShared.projitems b/Connectors/CSi/Speckle.Connectors.CSiShared/Speckle.Connectors.CSiShared.projitems index ebb05f5e1..f2edc1683 100644 --- a/Connectors/CSi/Speckle.Connectors.CSiShared/Speckle.Connectors.CSiShared.projitems +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Speckle.Connectors.CSiShared.projitems @@ -18,6 +18,7 @@ + @@ -27,12 +28,17 @@ + + + + + diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/AnalysisResultsExtractor.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/AnalysisResultsExtractor.cs new file mode 100644 index 000000000..56493de7a --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/AnalysisResultsExtractor.cs @@ -0,0 +1,184 @@ +using Speckle.Connectors.CSiShared.HostApp.Helpers; +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared; +using Speckle.Converters.CSiShared.Utils; +using Speckle.Sdk; +using Speckle.Sdk.Models; + +namespace Speckle.Connectors.CSiShared.Utils; + +public class AnalysisResultsExtractor +{ + private readonly IConverterSettingsStore _converterSettingsStore; + private readonly CsiResultsExtractorFactory _resultsExtractorFactory; + + public AnalysisResultsExtractor( + IConverterSettingsStore converterSettingsStore, + CsiResultsExtractorFactory resultsExtractorFactory + ) + { + _converterSettingsStore = converterSettingsStore; + _resultsExtractorFactory = resultsExtractorFactory; + } + + /// + /// Extracts complete analysis results including units retrieval, load case configuration, and results extraction. + /// Assumes inputs have been validated by caller. + /// + public Base ExtractAnalysisResults( + List selectedCasesAndCombinations, + List requestedResultTypes, + Dictionary> objectSelectionSummary + ) + { + // Step 1: get analysis units + var analysisResults = CreateAnalysisResultsWithUnits(); + + // Step 2: configure and validate load cases + ConfigureAndValidateSelectedLoadCases(selectedCasesAndCombinations); + + // Step 3: extract results using clean factory pattern + ExtractResults(requestedResultTypes, objectSelectionSummary, analysisResults); + + return analysisResults; + } + + /// + /// Instantiates a Base object and pre-populates it with the models defined force units. + /// + /// + /// + private Base CreateAnalysisResultsWithUnits() + { + var forceUnit = eForce.NotApplicable; + var lengthUnit = eLength.NotApplicable; + var temperatureUnit = eTemperature.NotApplicable; + + int success = _converterSettingsStore.Current.SapModel.GetDatabaseUnits_2( + ref forceUnit, + ref lengthUnit, + ref temperatureUnit + ); + + if (success != 0) + { + throw new SpeckleException("Failed to retrieve units for analysis results"); + } + + return new Base + { + ["forceUnit"] = forceUnit.ToString(), + ["lengthUnit"] = lengthUnit.ToString(), + ["temperatureUnit"] = temperatureUnit.ToString() + }; + } + + private void ExtractResults( + List requestedResultTypes, + Dictionary> objectSelectionSummary, + Base analysisResults + ) + { + foreach (var resultType in requestedResultTypes) + { + var extractor = _resultsExtractorFactory.GetExtractor(resultType); + objectSelectionSummary.TryGetValue(extractor.TargetObjectType, out var objectNames); + analysisResults[extractor.ResultsKey] = extractor.GetResults(objectNames); + } + } + + /// + /// Responsible for two things. Firstly, we need to setup the results so that only the requested cases and combinations + /// are published. Secondly, we need to ensure that the requested cases and combinations are actually run. + /// + private void ConfigureAndValidateSelectedLoadCases(List selectedLoadCases) + { + // step 1: configure load cases for output + ConfigureSelectedLoadCases(selectedLoadCases); + + // step 2: validate they are complete (throws on failure) + ValidateSelectedCasesAreComplete(selectedLoadCases); + } + + private void ConfigureSelectedLoadCases(List selectedLoadCases) + { + // deselect all load cases and combos + _converterSettingsStore.Current.SapModel.Results.Setup.DeselectAllCasesAndCombosForOutput(); + + // ui presents cases and combinations as a flat list. we need to distinguish if the string is a case or combo + // do this by seeing if the string is within the list of defined cases + // typically defined load cases << defined load combinations, so this approach should be more efficient + int numberOfLoadCases = 0; + string[] loadCaseNames = []; + _converterSettingsStore.Current.SapModel.LoadCases.GetNameList(ref numberOfLoadCases, ref loadCaseNames); + + // set user selected combos to true (i.e. to export) + foreach (var selectedLoadCase in selectedLoadCases) + { + int success = loadCaseNames.Contains(selectedLoadCase) + ? _converterSettingsStore.Current.SapModel.Results.Setup.SetCaseSelectedForOutput(selectedLoadCase) + : _converterSettingsStore.Current.SapModel.Results.Setup.SetComboSelectedForOutput(selectedLoadCase); + + // ui should only present valid options + // `AnalysisResultsExtractor` only fetches load cases and load combinations (not patterns), so this should never throw + if (success != 0) + { + throw new InvalidOperationException($"Failed to set {selectedLoadCase} for output."); + } + } + } + + private void ValidateSelectedCasesAreComplete(List selectedCasesAndCombinations) + { + // get status for all load cases (combinations not included in this API call) + int numberItems = 0; + string[] caseNames = []; + int[] statuses = []; + + int result = _converterSettingsStore.Current.SapModel.Analyze.GetCaseStatus( + ref numberItems, + ref caseNames, + ref statuses + ); + + if (result != 0) + { + throw new SpeckleException("Failed to retrieve load case status from model."); + } + + // build lookup dictionary for load cases only + var caseStatusLookup = caseNames + .Zip(statuses, (name, status) => new { name, status }) + .ToDictionary(x => x.name, x => x.status); + + // separate selected items into cases and combinations + var selectedCases = selectedCasesAndCombinations.Where(item => caseStatusLookup.ContainsKey(item)).ToList(); + var selectedCombinations = selectedCasesAndCombinations.Except(selectedCases).ToList(); + + // validate load cases status + var notFinishedCases = new List(); + foreach (var caseName in selectedCases) + { + int status = caseStatusLookup[caseName]; + if (status != 4) // 1 = Not run, 2 = Could not start, 3 = Not finished, 4 = Finished + { + notFinishedCases.Add($"{caseName}"); + } + } + + // TODO: Validate load combinations status + // for now, assume combinations are valid if we can't validate them + if (selectedCombinations.Count != 0) + { + // combinations validation not implemented - assuming they're valid for now + // it'll get complicated, we can't get the status of a combination, so we need to check the constituent cases + } + + if (notFinishedCases.Count != 0) + { + string errorMessage = + $"Analysis not complete for load cases: {string.Join(", ", notFinishedCases)}. Run analysis first."; + throw new SpeckleException(errorMessage); + } + } +} diff --git a/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/LoadCaseHelper.cs b/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/LoadCaseHelper.cs new file mode 100644 index 000000000..40d893a72 --- /dev/null +++ b/Connectors/CSi/Speckle.Connectors.CSiShared/Utils/LoadCaseHelper.cs @@ -0,0 +1,56 @@ +namespace Speckle.Connectors.CSiShared.Utils; + +public static class LoadCaseHelper +{ + public static List GetLoadCasesAndCombinations(cSapModel sapModel) + { + var loadCasesAndCombos = new List(); + + try + { + // Check if model is loaded to prevent crashes + var modelFilename = sapModel.GetModelFilename(); + if (string.IsNullOrEmpty(modelFilename)) + { + return loadCasesAndCombos; // Return empty list if no model + } + + // Get Load Cases + int numberItems = 0; + string[]? names = null; + + int ret = sapModel.LoadCases.GetNameList(ref numberItems, ref names); + if (ret == 0 && names != null) + { + for (int i = 0; i < numberItems; i++) + { + loadCasesAndCombos.Add(names[i]); + } + } + + // Get Load Combinations + numberItems = 0; + names = null; + ret = sapModel.RespCombo.GetNameList(ref numberItems, ref names); + if (ret == 0 && names != null) + { + for (int i = 0; i < numberItems; i++) + { + loadCasesAndCombos.Add(names[i]); + } + } + } + catch (System.Runtime.InteropServices.COMException) + { + // Return empty list on COM errors to prevent crashes + return new List(); + } + catch (System.InvalidOperationException) + { + // Return empty list on invalid operations to prevent crashes + return new List(); + } + + return loadCasesAndCombos.Distinct().OrderBy(x => x).ToList(); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettings.cs b/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettings.cs index 313c12637..ba1f5ff31 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettings.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettings.cs @@ -1,3 +1,8 @@ namespace Speckle.Converters.CSiShared; -public record CsiConversionSettings(cSapModel SapModel, string SpeckleUnits); +public record CsiConversionSettings( + cSapModel SapModel, + string SpeckleUnits, + List? SelectedLoadCasesAndCombinations = null, + List? SelectedResultTypes = null +); diff --git a/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettingsFactory.cs b/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettingsFactory.cs index bc6b13e3d..e93354e21 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettingsFactory.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/CsiConversionSettingsFactory.cs @@ -11,6 +11,15 @@ public class CsiConversionSettingsFactory( { public CsiConversionSettings Current => settingsStore.Current; - public CsiConversionSettings Create(cSapModel document) => - new(document, unitsConverter.ConvertOrThrow(document.GetPresentUnits())); + public CsiConversionSettings Create( + cSapModel document, + List? selectedLoadCasesAndCombinations = null, + List? selectedResultTypes = null + ) => + new( + document, + unitsConverter.ConvertOrThrow(document.GetPresentUnits()), + selectedLoadCasesAndCombinations ?? [], + selectedResultTypes ?? [] + ); } diff --git a/Converters/CSi/Speckle.Converters.CSiShared/CsiRootToSpeckleConverter.cs b/Converters/CSi/Speckle.Converters.CSiShared/CsiRootToSpeckleConverter.cs index a44a9d863..ca1b8afff 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/CsiRootToSpeckleConverter.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/CsiRootToSpeckleConverter.cs @@ -1,4 +1,3 @@ -using Microsoft.Extensions.Logging; using Speckle.Converters.Common; using Speckle.Converters.Common.Objects; using Speckle.Converters.Common.Registration; @@ -10,18 +9,10 @@ namespace Speckle.Converters.CSiShared; public class CsiRootToSpeckleConverter : IRootToSpeckleConverter { private readonly IConverterManager _toSpeckle; - private readonly IConverterSettingsStore _settingsStore; - private readonly ILogger _logger; - public CsiRootToSpeckleConverter( - IConverterManager toSpeckle, - IConverterSettingsStore settingsStore, - ILogger logger - ) + public CsiRootToSpeckleConverter(IConverterManager toSpeckle) { _toSpeckle = toSpeckle; - _settingsStore = settingsStore; - _logger = logger; } public Base Convert(object target) diff --git a/Converters/CSi/Speckle.Converters.CSiShared/Extensions/DatabaseTableExtensions.cs b/Converters/CSi/Speckle.Converters.CSiShared/Extensions/DatabaseTableExtensions.cs index 3b1569064..9a26e4660 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/Extensions/DatabaseTableExtensions.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/Extensions/DatabaseTableExtensions.cs @@ -22,23 +22,39 @@ namespace Speckle.Converters.CSiShared.Extensions; public record TableData { private readonly string[] _columnNames; // "fieldKeys" in api docs - private readonly string[] _rawTableData; // indicating raw, one-dimensional array of table data (before processing) - private readonly int _rowCount; // Number of rows - private IReadOnlyDictionary>? _processedRows; // Cached data structure + private readonly string[] _rawTableData; // raw, one-dimensional array of table data (before processing) + private readonly int _rowCount; // number of rows private readonly string _indexColumn; // column used to index/identify rows (typically, "UniqueName") + private readonly string[]? _additionalKeyColumns; // optional additional columns for compound keys (e.g. repeating "UniqueName") - public TableData(string[] columnNames, string[] rawTableData, int rowCount, string indexColumn) + private IReadOnlyDictionary>? _processedRows; // cached data structure + + /// + /// Creates a new TableData instance for processing CSI database table data. + /// + /// Array of column names in the table + /// Raw 1D array of table data from CSI API + /// Number of rows in the table + /// Primary column to use as row identifier + /// Optional additional columns to form compound keys for tables with non-unique primary keys + public TableData( + string[] columnNames, + string[] rawTableData, + int rowCount, + string indexColumn, + string[]? additionalKeyColumns = null + ) { _columnNames = columnNames; _rawTableData = rawTableData; _rowCount = rowCount; _indexColumn = indexColumn; + _additionalKeyColumns = additionalKeyColumns; } /// /// Gets table data as a dictionary mapping indexColumn (typically "UniqueName" to _processedRows). - /// Each row is itself a dictionary mapping column names to their values. - /// Computed once on first access and cached. + /// Each row is itself a dictionary mapping column names to their values. Computed once on first access and cached. /// /// /// Motivation: @@ -47,6 +63,11 @@ public record TableData /// Each row keyed by its "UniqueName" value /// Each row value is itself a dictionary of field keys to values /// + /// When additionalKeyColumns are provided, keys are formed by combining values from all key columns + /// using a pipe separator (|). + /// + /// If additionalKeyColumns are not provided and the table has multiple rows with the same primary key, + /// only the last row for each key will be preserved, and a warning will be logged. /// public IReadOnlyDictionary> Rows { @@ -67,8 +88,26 @@ public record TableData ); } + // Get indices for additional key columns if provided + int[] additionalKeyIndices = []; + if (_additionalKeyColumns != null && _additionalKeyColumns.Length > 0) + { + additionalKeyIndices = new int[_additionalKeyColumns.Length]; + for (int i = 0; i < _additionalKeyColumns.Length; i++) + { + additionalKeyIndices[i] = Array.IndexOf(_columnNames, _additionalKeyColumns[i]); + if (additionalKeyIndices[i] == -1) + { + throw new InvalidOperationException( + $"Additional key column '{_additionalKeyColumns[i]}' not found in the database." + ); + } + } + } + // Pre-size dictionary with known capacity var rows = new Dictionary>(_rowCount); + var keysSeen = new HashSet(); // Track keys to detect duplicates // Create a field index lookup to avoid repeated Array.IndexOf calls var fieldIndexLookup = new Dictionary(columnsPerRow); @@ -78,20 +117,56 @@ public record TableData } // Process each row + bool hasMultipleRowsPerKey = false; for (int rowStart = 0; rowStart < _rawTableData.Length; rowStart += columnsPerRow) { - var keyValue = _rawTableData[rowStart + indexColumnIndex]; + // Get the primary key value + var primaryKeyValue = _rawTableData[rowStart + indexColumnIndex]; - // Pre-size the row dictionary + // Construct the full key (either just primary key or compound key) + string fullKey; + if (additionalKeyIndices.Length > 0) + { + // Build compound key with additional columns + var keyParts = new string[1 + additionalKeyIndices.Length]; + keyParts[0] = primaryKeyValue; + + for (int i = 0; i < additionalKeyIndices.Length; i++) + { + keyParts[i + 1] = _rawTableData[rowStart + additionalKeyIndices[i]]; + } + + fullKey = string.Join("|", keyParts); + } + else + { + fullKey = primaryKeyValue; + } + + // Check if this key has been seen before (only matters if no additionalKeyColumns) + if (additionalKeyIndices.Length == 0 && keysSeen.Contains(primaryKeyValue)) + { + hasMultipleRowsPerKey = true; + } + keysSeen.Add(primaryKeyValue); + + // Create row dictionary var row = new Dictionary(columnsPerRow, StringComparer.Ordinal); - - // Use index lookup instead of repeated string comparisons foreach (var kvp in fieldIndexLookup) { row[kvp.Key] = _rawTableData[rowStart + kvp.Value]; } - rows[keyValue] = row; + rows[fullKey] = row; + } + + if (hasMultipleRowsPerKey && additionalKeyIndices.Length == 0) + { + System.Diagnostics.Debug.WriteLine( + $"WARNING: Table has multiple rows with the same primary key '{_indexColumn}'. " + + "Only the last row for each key is preserved. Consider specifying additionalKeyColumns " + + "when calling GetTableData to create compound keys." + ); } _processedRows = rows; @@ -126,4 +201,9 @@ public record TableData value = string.Empty; return false; } + + /// + /// Indicates whether this TableData was created with compound keys (additionalKeyColumns). + /// + public bool HasCompoundKeys => _additionalKeyColumns != null && _additionalKeyColumns.Length > 0; } diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ServiceRegistration.cs b/Converters/CSi/Speckle.Converters.CSiShared/ServiceRegistration.cs index b9a07b34c..53366cabd 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/ServiceRegistration.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/ServiceRegistration.cs @@ -24,6 +24,15 @@ public static class ServiceRegistration serviceCollection.AddScoped(); serviceCollection.AddScoped(); + // Register results extractors + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + serviceCollection.AddScoped(); + // Register connector caches serviceCollection.AddScoped(); diff --git a/Converters/CSi/Speckle.Converters.CSiShared/Speckle.Converters.CSiShared.projitems b/Converters/CSi/Speckle.Converters.CSiShared/Speckle.Converters.CSiShared.projitems index c8804b8fa..d420e0049 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/Speckle.Converters.CSiShared.projitems +++ b/Converters/CSi/Speckle.Converters.CSiShared/Speckle.Converters.CSiShared.projitems @@ -18,11 +18,19 @@ + + + + + + + + @@ -33,5 +41,6 @@ + \ No newline at end of file diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiBaseReactResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiBaseReactResultsExtractor.cs new file mode 100644 index 000000000..edaadb521 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiBaseReactResultsExtractor.cs @@ -0,0 +1,89 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public class CsiBaseReactResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + + public string ResultsKey => "baseReact"; + public ModelObjectType TargetObjectType => ModelObjectType.JOINT; + public ResultsConfiguration Configuration { get; } = + new(["LoadCase", "Wrap:StepNum"], ["FX", "FY", "FZ", "MX", "ParamMy", "MZ"]); + + public CsiBaseReactResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + // NOTE: since these are global reactions, they're independent of the user selection, therefore discarded + public Dictionary GetResults(IEnumerable? _) + { + // Step 1: define api variables + int numberResults = 0; + string[] loadCase = [], + stepType = []; + double[] stepNum = [], + fx = [], + fy = [], + fz = [], + mx = [], + paramMy = [], + mz = []; + double gx = 0, + gy = 0, + gz = 0; + + // Step 2: api call + int success = _settingsStore.Current.SapModel.Results.BaseReact( + ref numberResults, + ref loadCase, + ref stepType, + ref stepNum, + ref fx, + ref fy, + ref fz, + ref mx, + ref paramMy, + ref mz, + ref gx, + ref gy, + ref gz + ); + + if (success != 0 || numberResults == 0) + { + throw new InvalidOperationException("Base reaction extraction failed."); // shouldn't fail silently + } + + // Step 3: organise arrays for dictionary processor + var rawArrays = new Dictionary + { + ["LoadCase"] = loadCase, + ["StepNum"] = stepNum, + ["FX"] = fx, + ["FY"] = fy, + ["FZ"] = fz, + ["MX"] = mx, + ["ParamMy"] = paramMy, + ["MZ"] = mz + }; + + // Step 4: return sorted and processed dictionary + var resultsDictionary = _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + + // Step 5: add the extra centroid information + resultsDictionary["GX"] = gx; + resultsDictionary["GY"] = gy; + resultsDictionary["GZ"] = gz; + + // Step 6: return + return resultsDictionary; + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFrameForceResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFrameForceResultsExtractor.cs new file mode 100644 index 000000000..31233fcc6 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFrameForceResultsExtractor.cs @@ -0,0 +1,112 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public sealed class CsiFrameForceResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + + public string ResultsKey => "frameForces"; + public ModelObjectType TargetObjectType => ModelObjectType.FRAME; + + public ResultsConfiguration Configuration { get; } = + new(["Elm", "LoadCase", "Wrap:ElmSta", "Wrap:StepNum"], ["P", "V2", "V3", "T", "M2", "M3"]); + + public CsiFrameForceResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + public Dictionary GetResults(IEnumerable? objectNames = null) + { + // Step 1: validate input + var frameNames = objectNames?.ToList(); + if (frameNames is null || frameNames.Count == 0) + { + throw new InvalidOperationException("Frame(s) are required in the selection for results extraction"); + } + + // Step 2: single dictionary to accumulate all results + var allArrays = new Dictionary> + { + ["Elm"] = [], + ["ElmSta"] = [], + ["LoadCase"] = [], + ["StepNum"] = [], + ["P"] = [], + ["V2"] = [], + ["V3"] = [], + ["T"] = [], + ["M2"] = [], + ["M3"] = [] + }; + + // Step 3: define api variables + int numberResults = 0; + string[] obj = [], + elm = [], + loadCase = [], + stepType = []; + double[] objSta = [], + elmSta = [], + stepNum = [], + p = [], + v2 = [], + v3 = [], + t = [], + m2 = [], + m3 = []; + + // Step 4: iterate through objectNames and get frame results for those + foreach (string frameName in frameNames) + { + int success = _settingsStore.Current.SapModel.Results.FrameForce( + frameName, + eItemTypeElm.ObjectElm, + ref numberResults, + ref obj, + ref objSta, + ref elm, + ref elmSta, + ref loadCase, + ref stepType, + ref stepNum, + ref p, + ref v2, + ref v3, + ref t, + ref m2, + ref m3 + ); + + if (success != 0) + { + throw new InvalidOperationException($"Frame force extraction failed for frame {frameName}."); // shouldn't fail silently + } + + // accumulate results + allArrays["Elm"].AddRange(elm.Cast()); + allArrays["ElmSta"].AddRange(elmSta.Cast()); + allArrays["LoadCase"].AddRange(loadCase.Cast()); + allArrays["StepNum"].AddRange(stepNum.Cast()); + allArrays["P"].AddRange(p.Cast()); + allArrays["V2"].AddRange(v2.Cast()); + allArrays["V3"].AddRange(v3.Cast()); + allArrays["T"].AddRange(t.Cast()); + allArrays["M2"].AddRange(m2.Cast()); + allArrays["M3"].AddRange(m3.Cast()); + } + + // Step 5: organise arrays for dictionary processor + var rawArrays = allArrays.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value.ToArray()); + + // Step 6: return sorted and processed dictionary + return _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFramePropertiesExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFramePropertiesExtractor.cs index ba3093b69..48378e9d5 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFramePropertiesExtractor.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiFramePropertiesExtractor.cs @@ -26,7 +26,6 @@ namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; public sealed class CsiFramePropertiesExtractor { private readonly IConverterSettingsStore _settingsStore; - private readonly CsiToSpeckleCacheSingleton _csiToSpeckleCacheSingleton; private static readonly string[] s_releaseKeys = diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiJointReactResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiJointReactResultsExtractor.cs new file mode 100644 index 000000000..89fe84d9f --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiJointReactResultsExtractor.cs @@ -0,0 +1,115 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public class CsiJointReactResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + + public string ResultsKey => "jointReact"; + public ModelObjectType TargetObjectType => ModelObjectType.JOINT; + public ResultsConfiguration Configuration { get; } = + new(["Elm", "LoadCase", "Wrap:StepNum"], ["F1", "F2", "F3", "M1", "M2", "M3"]); + + public CsiJointReactResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + public Dictionary GetResults(IEnumerable? objectNames = null) + { + // Step 1: validate input + var jointNames = objectNames?.ToList(); + if (jointNames is null || jointNames.Count == 0) + { + throw new InvalidOperationException("Joint(s) are required in the selection for results extraction"); + } + + // Step 2: single dictionary to accumulate all results + var allArrays = new Dictionary> + { + ["Elm"] = [], + ["LoadCase"] = [], + ["StepNum"] = [], + ["F1"] = [], + ["F2"] = [], + ["F3"] = [], + ["M1"] = [], + ["M2"] = [], + ["M3"] = [] + }; + + // Step 3: define api variables + int numberResults = 0; + string[] obj = [], + elm = [], + loadCase = [], + stepType = []; + double[] stepNum = [], + f1 = [], + f2 = [], + f3 = [], + m1 = [], + m2 = [], + m3 = []; + + // Step 4: iterate through objectNames and get joint reaction results for those that are assigned restraints / springs and grounded + foreach (string jointName in jointNames) + { + // this only works if the joint has restraints or springs assignments, so check if it's a valid query first + bool[] restraints = []; + string springAssignment = string.Empty; + _settingsStore.Current.SapModel.PointObj.GetRestraint(jointName, ref restraints); + _settingsStore.Current.SapModel.PointObj.GetSpringAssignment(jointName, ref springAssignment); + if (restraints.All(r => !r) && string.IsNullOrEmpty(springAssignment)) + { + continue; // skip this joint - it has neither restraints nor springs + } + + int success = _settingsStore.Current.SapModel.Results.JointReact( + jointName, + eItemTypeElm.ObjectElm, + ref numberResults, + ref obj, + ref elm, + ref loadCase, + ref stepType, + ref stepNum, + ref f1, + ref f2, + ref f3, + ref m1, + ref m2, + ref m3 + ); + + if (success != 0) + { + throw new InvalidOperationException($"Joint force extraction failed for frame {jointName}."); // shouldn't fail silently + } + + // accumulate results + allArrays["Elm"].AddRange(elm.Cast()); + allArrays["LoadCase"].AddRange(loadCase.Cast()); + allArrays["StepNum"].AddRange(stepNum.Cast()); + allArrays["F1"].AddRange(f1.Cast()); + allArrays["F2"].AddRange(f2.Cast()); + allArrays["F3"].AddRange(f3.Cast()); + allArrays["M1"].AddRange(m1.Cast()); + allArrays["M2"].AddRange(m2.Cast()); + allArrays["M3"].AddRange(m3.Cast()); + } + + // Step 5: organise arrays for dictionary processor + var rawArrays = allArrays.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value.ToArray()); + + // Step 6: return sorted and processed dictionary + return _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiPierForceResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiPierForceResultsExtractor.cs new file mode 100644 index 000000000..99b77cd7b --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiPierForceResultsExtractor.cs @@ -0,0 +1,78 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public class CsiPierForceResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + public string ResultsKey => "pierForces"; + public ModelObjectType TargetObjectType => ModelObjectType.NONE; + public ResultsConfiguration Configuration { get; } = + new(["PierName", "StoryName", "LoadCase", "Wrap:Location"], ["P", "V2", "V3", "T", "M2", "M3"]); + + public CsiPierForceResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + // NOTE: since these pier assignments aren't object specific, they're independent of the user selection, therefore discarded + public Dictionary GetResults(IEnumerable? _) + { + // Step 1: define api variables + int numberResults = 0; + string[] storyName = [], + pierName = [], + loadCase = [], + location = []; + double[] p = [], + v2 = [], + v3 = [], + t = [], + m2 = [], + m3 = []; + + // Step 2: api call + int success = _settingsStore.Current.SapModel.Results.PierForce( + ref numberResults, + ref storyName, + ref pierName, + ref loadCase, + ref location, + ref p, + ref v2, + ref v3, + ref t, + ref m2, + ref m3 + ); + + if (success != 0 || numberResults == 0) + { + throw new InvalidOperationException("Pier force extraction failed."); + } + + // Step 3: organise arrays for dictionary processor + var rawArrays = new Dictionary + { + ["StoryName"] = storyName, + ["PierName"] = pierName, + ["LoadCase"] = loadCase, + ["Location"] = location, + ["P"] = p, + ["V2"] = v2, + ["V3"] = v3, + ["T"] = t, + ["M2"] = m2, + ["M3"] = m3 + }; + + // Step 4: return sorted and processed dictionary + return _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiSpandrelForceResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiSpandrelForceResultsExtractor.cs new file mode 100644 index 000000000..5fa41d804 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiSpandrelForceResultsExtractor.cs @@ -0,0 +1,79 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public class CsiSpandrelForceResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + public string ResultsKey => "spandrelForces"; + public ModelObjectType TargetObjectType => ModelObjectType.NONE; + + public ResultsConfiguration Configuration { get; } = + new(["SpandrelName", "StoryName", "LoadCase", "Wrap:Location"], ["P", "V2", "V3", "T", "M2", "M3"]); + + public CsiSpandrelForceResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + // NOTE: since these spandrel assignments aren't object specific, they're independent of the user selection, therefore discarded + public Dictionary GetResults(IEnumerable? _) + { + // Step 1: define api variables + int numberResults = 0; + string[] storyName = [], + spandrelName = [], + loadCase = [], + location = []; + double[] p = [], + v2 = [], + v3 = [], + t = [], + m2 = [], + m3 = []; + + // Step 2: api call + int success = _settingsStore.Current.SapModel.Results.SpandrelForce( + ref numberResults, + ref storyName, + ref spandrelName, + ref loadCase, + ref location, + ref p, + ref v2, + ref v3, + ref t, + ref m2, + ref m3 + ); + + if (success != 0 || numberResults == 0) + { + throw new InvalidOperationException("Spandrel force extraction failed."); // shouldn't fail silently + } + + // Step 3: organise arrays for dictionary processor + var rawArrays = new Dictionary + { + ["StoryName"] = storyName, + ["SpandrelName"] = spandrelName, + ["LoadCase"] = loadCase, + ["Location"] = location, + ["P"] = p, + ["V2"] = v2, + ["V3"] = v3, + ["T"] = t, + ["M2"] = m2, + ["M3"] = m3 + }; + + // Step 4: return sorted and processed dictionary + return _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiStoryDriftsResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiStoryDriftsResultsExtractor.cs new file mode 100644 index 000000000..28096c97f --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/CsiStoryDriftsResultsExtractor.cs @@ -0,0 +1,78 @@ +using Speckle.Converters.Common; +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +public class CsiStoryDriftsResultsExtractor : IApplicationResultsExtractor +{ + private readonly IConverterSettingsStore _settingsStore; + private readonly ResultsArrayProcessor _resultsArrayProcessor; + public string ResultsKey => "storyDrifts"; + public ModelObjectType TargetObjectType => ModelObjectType.NONE; + + public ResultsConfiguration Configuration { get; } = + new(["Story", "LoadCase", "Wrap:StepNum"], ["Direction", "Drift", "Label", "X", "Y", "Z"]); + + public CsiStoryDriftsResultsExtractor( + IConverterSettingsStore settingsStore, + ResultsArrayProcessor resultsArrayProcessor + ) + { + _settingsStore = settingsStore; + _resultsArrayProcessor = resultsArrayProcessor; + } + + // NOTE: these aren't object specific, they're independent of the user selection, therefore discarded + public Dictionary GetResults(IEnumerable? objectNames = null) + { + // Step 1: define api variables + int numberResults = 0; + string[] story = [], + loadCase = [], + stepType = [], + direction = [], + label = []; + double[] stepNum = [], + drift = [], + x = [], + y = [], + z = []; + + // Step 2: api call + int success = _settingsStore.Current.SapModel.Results.StoryDrifts( + ref numberResults, + ref story, + ref loadCase, + ref stepType, + ref stepNum, + ref direction, + ref drift, + ref label, + ref x, + ref y, + ref z + ); + + if (success != 0 || numberResults == 0) + { + throw new InvalidOperationException("Story drifts extraction failed."); // shouldn't fail silently + } + + // Step 3: organise arrays for dictionary processor + var rawArrays = new Dictionary + { + ["Story"] = story, + ["LoadCase"] = loadCase, + ["StepNum"] = stepNum, + ["Direction"] = direction, + ["Drift"] = drift, + ["Label"] = label, + ["X"] = x, + ["Y"] = y, + ["Z"] = z + }; + + // Step 4: return sorted and processed dictionary + return _resultsArrayProcessor.ProcessArrays(rawArrays, Configuration); + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/DatabaseTableExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/DatabaseTableExtractor.cs index b3580943a..42b2958c8 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/DatabaseTableExtractor.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/DatabaseTableExtractor.cs @@ -24,42 +24,48 @@ public class DatabaseTableExtractor /// /// Uses the cDatabaseTables.GetTableForDisplayArray() to request data for a specified table name. - /// Processes the one-dimensional array return with the - /// extension for improved workability/reliability. /// - /// String identifying the table to fetch. This typically matches the UI. - /// Key used to organize and (later) lookup specific rows of data. Optional argument, default is "UniqueName" - /// Optional list of specific fields to fetch. If null or empty, all fields will be returned. Ask Björn about how to determine these strings. + /// String identifying the table to fetch + /// Primary column to use as row identifier (default "UniqueName") + /// Optional specific fields to fetch + /// Optional additional columns to form compound keys for tables with non-unique primary keys /// TableData containing the requested fields and records - public TableData GetTableData(string tableName, string? indexingColumn = null, string[]? requestedColumns = null) + public TableData GetTableData( + string tableName, + string? indexingColumn = null, + string[]? requestedColumns = null, + string[]? additionalKeyColumns = null + ) { + // Create a cache key that includes additionalKeyColumns if provided string tableKeyField = indexingColumn ?? DEFAULT_KEY_FIELD; // most queries will use "UniqueName" - string cacheKey = $"{tableName}_{tableKeyField}"; + string additionalKeysString = additionalKeyColumns != null ? string.Join(",", additionalKeyColumns) : ""; + string cacheKey = $"{tableName}_{tableKeyField}_{additionalKeysString}"; + if (_tableCache.TryGetValue(cacheKey, out var cachedData)) { return cachedData; } - var tableData = FetchTableData(tableName, tableKeyField, requestedColumns); + var tableData = FetchTableData(tableName, tableKeyField, requestedColumns, additionalKeyColumns); _tableCache[cacheKey] = tableData; return tableData; } - public void RefreshTable(string tableKey, string? keyField = null) => - _tableCache.Remove($"{tableKey}_{keyField ?? DEFAULT_KEY_FIELD}"); - - public void ClearCache() => _tableCache.Clear(); - - private TableData FetchTableData(string tableName, string indexingColumn, string[]? requestedColumns = null) + private TableData FetchTableData( + string tableName, + string indexingColumn, + string[]? requestedColumns = null, + string[]? additionalKeyColumns = null + ) { - string[] requestedFields = requestedColumns ?? []; // only fetch the keys needed (memory reduction potential) + string[] requestedFields = requestedColumns ?? []; // only fetch the keys needed string[] fieldsKeysIncluded = []; - string[] tableData = []; // one-dimensional gross mess + string[] tableData = []; // one-dimensional array int tableVersion = 0; int numberOfRecords = 0; - // ensure indexingColumn is included in the requested fields - // if user forgets to include indexingColumn in requestedColumns => problem when it comes to creating dictionaries! + // Ensure indexingColumn is included if (requestedFields != Array.Empty() && !requestedFields.Contains(indexingColumn)) { requestedFields = [.. requestedFields, indexingColumn]; @@ -68,7 +74,7 @@ public class DatabaseTableExtractor var result = _settingsStore.Current.SapModel.DatabaseTables.GetTableForDisplayArray( tableName, ref requestedFields, - string.Empty, // empty means all objects (not group-specific) + string.Empty, // empty means all objects ref tableVersion, ref fieldsKeysIncluded, ref numberOfRecords, @@ -82,6 +88,6 @@ public class DatabaseTableExtractor ); } - return new TableData(fieldsKeysIncluded, tableData, numberOfRecords, indexingColumn); + return new TableData(fieldsKeysIncluded, tableData, numberOfRecords, indexingColumn, additionalKeyColumns); } } diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/IApplicationResultsExtractor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/IApplicationResultsExtractor.cs new file mode 100644 index 000000000..e1f2be577 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/IApplicationResultsExtractor.cs @@ -0,0 +1,36 @@ +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +/// +/// Implementations handle specific result types (frame forces, joint reactions, etc.) and +/// transform raw API data into hierarchical dictionary structures. +/// +public interface IApplicationResultsExtractor +{ + /// + /// Gets the key name used to store these results in the root commit object. + /// + /// "FrameForces", "JointReactions", "BaseReactions" + string ResultsKey { get; } + + /// + /// Gets the type of CSI model objects that this extractor operates on. + /// Used to automatically resolve the correct object names from the selection summary. + /// + ModelObjectType TargetObjectType { get; } + + /// + /// Gets the configuration defining how to process raw API arrays into hierarchical structure. + /// Specifies grouping hierarchy and result value keys. + /// + ResultsConfiguration Configuration { get; } + + /// + /// Extracts analysis results for the specified objects and processes them into hierarchical format. + /// + /// + /// Names of objects to extract results for. Some extractors don't extract object specific results, hence null. + /// + Dictionary GetResults(IEnumerable? objectNames = null); +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/ResultsArrayProcessor.cs b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/ResultsArrayProcessor.cs new file mode 100644 index 000000000..9817be4f5 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/ToSpeckle/Helpers/ResultsArrayProcessor.cs @@ -0,0 +1,98 @@ +using Speckle.Converters.CSiShared.Utils; + +namespace Speckle.Converters.CSiShared.ToSpeckle.Helpers; + +/// +/// Processes parallel arrays from analysis results into hierarchical dictionary structures. +/// Uses configuration to determine grouping hierarchy and result values. +/// +public class ResultsArrayProcessor +{ + /// + /// Transforms parallel arrays into nested dictionary hierarchy based on configuration. + /// + /// Dictionary of array names to their values (all arrays must have same length) + /// Configuration defining grouping keys and result keys + /// Nested dictionary following GroupingKeys hierarchy with ResultKeys as leaf values + public Dictionary ProcessArrays(Dictionary rawArrays, ResultsConfiguration config) + { + // get array length from first array (all should be same length) + var firstArray = rawArrays.Values.FirstOrDefault(); + if (firstArray is not Array array || array.Length == 0) + { + return new Dictionary(); + } + + int arrayLength = array.Length; + + // create indices for all rows + var indices = Enumerable.Range(0, arrayLength); + + // build the hierarchy recursively + return BuildHierarchy(indices, rawArrays, config.GroupingKeys, config.ResultKeys, 0); + } + + private Dictionary BuildHierarchy( + IEnumerable indices, + Dictionary rawArrays, + IReadOnlyList groupingKeys, + IReadOnlyList resultKeys, + int level + ) + { + // Base case: we've processed all grouping levels, create result values + if (level >= groupingKeys.Count) + { + var results = new Dictionary(); + var firstIndex = indices.First(); + + foreach (var resultKey in resultKeys) + { + if (rawArrays.TryGetValue(resultKey, out var array) && array is Array resultArray) + { + var value = resultArray.GetValue(firstIndex); + if (value != null) + { + results[resultKey] = value; + } + } + } + + return results; + } + + // Get current grouping key + var currentKey = groupingKeys[level]; + string actualKey; + string? wrapperName = null; + + // Check for "Wrap:" prefix + if (currentKey.StartsWith("Wrap:")) + { + actualKey = currentKey["Wrap:".Length..]; + wrapperName = actualKey; // Use the actual key name as wrapper + } + else + { + actualKey = currentKey; + } + + if (!rawArrays.TryGetValue(actualKey, out var groupingArray) || groupingArray is not Array currentArray) + { + throw new ArgumentException($"Grouping key '{actualKey}' not found in raw arrays"); + } + + // Group indices by the current key's values + var grouped = indices + .GroupBy(i => currentArray.GetValue(i)?.ToString() ?? string.Empty) + .ToDictionary(g => g.Key, g => (object)BuildHierarchy(g, rawArrays, groupingKeys, resultKeys, level + 1)); + + // Wrap if needed + if (wrapperName != null) + { + return new Dictionary { [wrapperName] = grouped }; + } + + return grouped; + } +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/Utils/Constants.cs b/Converters/CSi/Speckle.Converters.CSiShared/Utils/Constants.cs index 49995873a..87a916ae7 100644 --- a/Converters/CSi/Speckle.Converters.CSiShared/Utils/Constants.cs +++ b/Converters/CSi/Speckle.Converters.CSiShared/Utils/Constants.cs @@ -52,3 +52,27 @@ public static class CommonObjectProperty public const string ADVANCED = "Advanced"; public const string DESIGN_ORIENTATION = "Design Orientation"; } + +/// +/// These strings are repeated when defining UI dropdown list `ResultTypeSetting.cs` as well as `CsiResultsExtractorFactory.cs`/> +/// +public static class ResultsKey +{ + public const string BASE_REACT = "Base Reactions"; + public const string FRAME_FORCES = "Frame Forces"; + public const string JOINT_REACT = "Joint Reactions"; + public const string PIER_FORCES = "Pier Forces"; + public const string SPANDREL_FORCES = "Spandrel Forces"; + public const string STORY_DRIFTS = "Story Drifts"; + + // Used by ResultTypeSetting to get all defined result keys + public static readonly string[] All = + [ + BASE_REACT, + FRAME_FORCES, + JOINT_REACT, + PIER_FORCES, + SPANDREL_FORCES, + STORY_DRIFTS + ]; +} diff --git a/Converters/CSi/Speckle.Converters.CSiShared/Utils/ResultsConfiguration.cs b/Converters/CSi/Speckle.Converters.CSiShared/Utils/ResultsConfiguration.cs new file mode 100644 index 000000000..22201f5f0 --- /dev/null +++ b/Converters/CSi/Speckle.Converters.CSiShared/Utils/ResultsConfiguration.cs @@ -0,0 +1,8 @@ +namespace Speckle.Converters.CSiShared.Utils; + +/// +/// Configuration used by all IResultsExtractor to sort results arrays into a hierarchical structure. +/// +/// Keys defining the hierarchy levels for grouping results, in order from top-level to bottom-level +/// Keys for the final result values stored at the leaf level of the hierarchy +public record ResultsConfiguration(IReadOnlyList GroupingKeys, IReadOnlyList ResultKeys);