Skip to content

Commit 4411080

Browse files
rwmcintoshclaude
andauthored
Fixes #2189 Batch update simulation from building block (#2276)
* Fixes #2189 Batch update simulation from building block * #2189 Update simulations from building blocks in parallel with per-simulation status Adds a parallel update flow with a modal showing per-simulation status (Waiting/Updating/Pending/Updated/Faulted/Canceled): - ParallelSimulationUpdateTask routes single-simulation updates to the existing path and multi-simulation updates to SimulationUpdatePresenter, which configures the models off the UI thread and applies them. - SimulationUpdateTask.ConfigureSimulationsInParallel builds each model in parallel and publishes SimulationConfigurationStarted/Finished events; the presenter listens and updates each simulation's status. - Cancel stops simulations that have not started configuring; in-progress ones finish. Configured-but-invalid simulations are reported as Faulted (not Canceled), and the command description counts only the simulations actually updated. - ApplicationStartup registers the UI thread and a splash-thread-bound publisher so events dispatch on the correct thread. Also bumps OSPSuite.Core to 13.0.129 and OSPSuite.Utility to 5.0.0.10, and moves the shared spec helpers (SynchronousSynchronizationContextForSpecs, ConcurrencyManagerForSpecs) into tests/MoBi.Tests/Helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Normalize CreateModelAndValidate to a single non-null result contract ModelConstructor.CreateModelFrom never returns null - it returns an invalid CreationResult (carrying the validation messages) when the build fails - so the results == null branches were dead and only produced a spurious null failure shape. Drop them so CreateModelAndValidate(throwOnInvalid: false) always returns a non-null result and callers check IsInvalid instead of null. XML doc and the spec mock aligned with the single contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Pluralize the configured-simulations command description Use the OSPSuite.Core PluralizeIf string extension so the command-history description reads "Configured 1 simulation" / "Configured 2 simulations" instead of always pluralizing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Always emit a terminal configuration event with the correct outcome Replace SimulationConfigurationFinishedEvent with distinct SimulationConfigurationSucceededEvent / SimulationConfigurationFailedEvent and publish one from a finally block, choosing by result validity. A started row now always reaches a terminal state, and an invalid result shows Faulted instead of the misleading Pending it showed when only a single "finished" event was raised. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drop the redundant manual listener teardown from SimulationUpdatePresenter The presenter is registered as a listener automatically by the EventRegisterFacility when the container resolves it, and the EventPublisher holds listeners by weak reference and prunes dead ones on each publish. The explicit RemoveListener in Cleanup was therefore unnecessary for this short-lived modal presenter (only long-lived shell presenters need it). Removing it also makes the IEventPublisher dependency dead, so drop it and the Cleanup override entirely. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Guard against a null command from ConfigureSimulation _heavyWorkManager.Start swallows exceptions thrown by its action, so when the model build fails the captured command stays null and would flow into AddToHistory(null). Fall back to a MoBiEmptyCommand, matching the existing guards in this method. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Always complete the update dialog even if the configuration task faults The ContinueWith dereferenced configurationTask.Result directly, so a faulted task would throw inside the continuation, skip applyConfigurations, and leave the modal stuck because _view.ShowCompleted() never ran. Pass the task instead and apply results only when it ran to completion, keeping ShowCompleted in the finally so the dialog always closes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Keep successfully configured simulations when the batch hits an unexpected error ConfigureSimulationsInParallel only caught OperationCanceledException, so an unexpected throw escaping a worker (e.g. from a PublishEvent) faulted the task and discarded every result - including the simulations that had already been configured. Broaden the catch so the method always returns its partial results; the presenter then applies the successful ones and marks the rest Faulted (or Canceled when the user cancelled), matching the per-simulation isolation already used inside the worker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Show "Failed" instead of "Faulted" in the simulation update status Map RunStatusId.Faulted to a "Failed" caption in the status grid rather than falling back to the RunStatus display name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a78c4d4 commit 4411080

25 files changed

Lines changed: 1230 additions & 99 deletions

src/MoBi.Assets/AppConstants.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,8 @@ public static string SetModuleMergeBehavior(string moduleName, string newMergeBe
861861
{
862862
return $"Set merge behavior to '{newMergeBehavior}' for module '{moduleName}'";
863863
}
864+
865+
public static string ConfigureSimulationsDescription(int count) => $"Configured {count} {"simulation".PluralizeIf(count)}";
864866
}
865867

866868
public static class BarNames
@@ -1622,6 +1624,14 @@ public static class Captions
16221624
public static readonly string BuildingBlockType = "Building Block Type";
16231625
public static readonly string BuildingBlockName = "Building Block Name";
16241626
public static readonly string Message = "Message";
1627+
public static readonly string UpdateStatus = "Status";
1628+
public static readonly string Close = "Close";
1629+
public static readonly string Waiting = "Waiting";
1630+
public static readonly string Updating = "Updating";
1631+
public static readonly string Pending = "Pending";
1632+
public static readonly string Updated = "Updated";
1633+
public static readonly string Failed = "Failed";
1634+
public static readonly string CancelSimulationUpdateClarification = "Cancel stops only simulations that have not started updating. Updates already in progress will finish.";
16251635
public static readonly string SaveToFile = "Save...";
16261636
public static readonly string DecimalPlace = "Decimal place";
16271637
public static readonly string SelectDataToExport = "Select data to Export";
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using OSPSuite.Core.Domain;
2+
using SimulationConfiguration = OSPSuite.Core.Domain.Builder.SimulationConfiguration;
3+
4+
namespace MoBi.Core.Domain.Model;
5+
6+
public class ModelCreationAndValidationResult
7+
{
8+
public IMoBiSimulation Simulation { get; }
9+
public CreationResult CreationResult { get; set; }
10+
public SimulationConfiguration ClonedConfiguration { get; set; }
11+
12+
public ModelCreationAndValidationResult(IMoBiSimulation simulation)
13+
{
14+
Simulation = simulation;
15+
}
16+
17+
public bool IsValid => CreationResult != null && !CreationResult.IsInvalid;
18+
19+
public bool WasConfigured => CreationResult != null || ClonedConfiguration != null;
20+
}

src/MoBi.Core/Domain/Services/SimulationFactory.cs

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ public interface ISimulationFactory
2828

2929
SimulationAndValidationResult CreateSimulationAndValidate(SimulationConfiguration configurationReferencingBuildingBlocks, string simulationName);
3030

31-
CreationResult CreateModelAndValidate(SimulationConfiguration simulationConfiguration, string modelName, string message = AppConstants.Captions.ConfiguringSimulation);
31+
/// <summary>
32+
/// Creates the model from <paramref name="simulationConfiguration" /> and validates its dimensions. With
33+
/// <paramref name="throwOnInvalid" /> <c>true</c> (the default) an invalid model throws
34+
/// <see cref="ValidationFailedMoBiException" />; pass <c>false</c> to get the result back instead — always
35+
/// non-null, with <c>IsInvalid</c> set when the model could not be created.
36+
/// </summary>
37+
CreationResult CreateModelAndValidate(SimulationConfiguration simulationConfiguration, string modelName, bool throwOnInvalid = true);
3238
}
3339

3440
public class SimulationFactory : ISimulationFactory
@@ -94,14 +100,17 @@ private void validateDimensions(IModel model, SimulationBuilder simulationBuilde
94100
.SecureContinueWith(t => showWarnings(t.Result));
95101
}
96102

97-
public CreationResult CreateModelAndValidate(SimulationConfiguration simulationConfiguration, string modelName, string message = AppConstants.Captions.ConfiguringSimulation)
103+
public CreationResult CreateModelAndValidate(SimulationConfiguration simulationConfiguration, string modelName, bool throwOnInvalid = true)
98104
{
99-
CreationResult results = null;
105+
var results = createModel(simulationConfiguration, modelName);
100106

101-
results = createModel(simulationConfiguration, modelName);
107+
if (results.IsInvalid)
108+
{
109+
if (throwOnInvalid)
110+
throw new ValidationFailedMoBiException(AppConstants.Exceptions.CouldNotCreateSimulation, results.ValidationResult);
102111

103-
if (results == null || results.IsInvalid)
104-
throw new ValidationFailedMoBiException(AppConstants.Exceptions.CouldNotCreateSimulation, results?.ValidationResult);
112+
return results;
113+
}
105114

106115
validateDimensions(results.Model, results.SimulationBuilder);
107116

@@ -110,20 +119,17 @@ public CreationResult CreateModelAndValidate(SimulationConfiguration simulationC
110119

111120
public SimulationAndValidationResult CreateSimulationAndValidate(SimulationConfiguration configurationReferencingBuildingBlocks, string simulationName)
112121
{
113-
var results = CreateModelAndValidate(configurationReferencingBuildingBlocks, simulationName, AppConstants.Captions.CreatingSimulation);
122+
var results = CreateModelAndValidate(configurationReferencingBuildingBlocks, simulationName);
114123
var clonedConfiguration = _cloneManager.Clone(configurationReferencingBuildingBlocks);
115124
var simulation = CreateFrom(clonedConfiguration, results.Model, results.SimulationBuilder.EntitySources).WithName(simulationName);
116125
return new SimulationAndValidationResult(simulation, results.ValidationResult);
117126
}
118127

119128
private CreationResult createModel(SimulationConfiguration simulationConfiguration, string name)
120129
{
130+
//CreateModelFrom always returns a result - an invalid one carrying the validation messages when the build fails - never null
121131
var result = _modelConstructor.CreateModelFrom(simulationConfiguration, name);
122-
if (result == null)
123-
return null;
124-
125132
showWarnings(result.ValidationResult);
126-
127133
return result;
128134
}
129135

src/MoBi.Core/Events/Events.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,36 @@ public SimulationRunStartedEvent(IMoBiSimulation simulation)
3636
public IMoBiSimulation Simulation { get; }
3737
}
3838

39+
public class SimulationConfigurationStartedEvent
40+
{
41+
public SimulationConfigurationStartedEvent(IMoBiSimulation simulation)
42+
{
43+
Simulation = simulation;
44+
}
45+
46+
public IMoBiSimulation Simulation { get; }
47+
}
48+
49+
public class SimulationConfigurationSucceededEvent
50+
{
51+
public SimulationConfigurationSucceededEvent(IMoBiSimulation simulation)
52+
{
53+
Simulation = simulation;
54+
}
55+
56+
public IMoBiSimulation Simulation { get; }
57+
}
58+
59+
public class SimulationConfigurationFailedEvent
60+
{
61+
public SimulationConfigurationFailedEvent(IMoBiSimulation simulation)
62+
{
63+
Simulation = simulation;
64+
}
65+
66+
public IMoBiSimulation Simulation { get; }
67+
}
68+
3969
public class SimulationAddedEvent
4070
{
4171
public SimulationAddedEvent(IMoBiSimulation simulation)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using MoBi.Assets;
2+
using OSPSuite.Assets;
3+
using OSPSuite.Core.Domain.ParameterIdentifications;
4+
5+
namespace MoBi.Presentation.DTO;
6+
7+
public class SimulationUpdateStatusDTO
8+
{
9+
public string SimulationId { get; init; }
10+
public string Name { get; init; }
11+
public RunStatus Status { get; set; }
12+
13+
public ApplicationIcon StatusIcon => Status?.Id == RunStatusId.Created ? ApplicationIcons.Time : ApplicationIcons.IconFor(Status);
14+
15+
public string StatusDisplay => Status?.Id switch
16+
{
17+
RunStatusId.WaitingToRun => AppConstants.Captions.Waiting,
18+
RunStatusId.Running => AppConstants.Captions.Updating,
19+
RunStatusId.Created => AppConstants.Captions.Pending,
20+
RunStatusId.RanToCompletion => AppConstants.Captions.Updated,
21+
RunStatusId.Faulted => AppConstants.Captions.Failed,
22+
_ => Status?.DisplayName
23+
};
24+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using MoBi.Core.Domain.Model;
2+
using MoBi.Presentation.DTO;
3+
using OSPSuite.Core.Domain.ParameterIdentifications;
4+
using OSPSuite.Utility;
5+
6+
namespace MoBi.Presentation.Mappers;
7+
8+
public interface ISimulationToSimulationUpdateStatusDTOMapper : IMapper<IMoBiSimulation, SimulationUpdateStatusDTO>
9+
{
10+
}
11+
12+
public class SimulationToSimulationUpdateStatusDTOMapper : ISimulationToSimulationUpdateStatusDTOMapper
13+
{
14+
public SimulationUpdateStatusDTO MapFrom(IMoBiSimulation simulation) =>
15+
new SimulationUpdateStatusDTO { SimulationId = simulation.Id, Name = simulation.Name, Status = RunStatus.WaitingToRun };
16+
}

src/MoBi.Presentation/MenusAndBars/ContextMenus/ContextMenuForSimulation.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ private IMenuBarItem createUpdate(IMoBiSimulation simulation)
243243
return CreateMenuButton.WithCaption(AppConstants.MenuNames.UpdateFromBuildingBlocks)
244244
.WithEnabled(_simulationRunner.IsSimulationIdle(simulation))
245245
.WithIcon(ApplicationIcons.Update)
246-
.WithCommandFor<UpdateSimulationUICommand, IMoBiSimulation>(simulation, _container);
246+
.WithCommandFor<UpdateSimulationUICommand, IReadOnlyList<IMoBiSimulation>>([simulation], _container);
247247
}
248248

249249
private IMenuBarItem createConfigure(IMoBiSimulation simulation)

src/MoBi.Presentation/MenusAndBars/ContextMenus/MultipleSimulationNodeContextMenuFactory.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ protected override IEnumerable<IMenuBarItem> AllMenuItemsFor(IReadOnlyList<IMoBi
5353
.WithCommandFor<RunSimulationsCommand, IReadOnlyList<IMoBiSimulation>>(simulations, _container)
5454
.WithIcon(ApplicationIcons.Run);
5555

56+
yield return CreateMenuButton.WithCaption(AppConstants.MenuNames.UpdateFromBuildingBlocks)
57+
.WithEnabled(allSimulationsAreIdle(simulations))
58+
.WithIcon(ApplicationIcons.Update)
59+
.WithCommandFor<UpdateSimulationUICommand, IReadOnlyList<IMoBiSimulation>>(simulations, _container);
60+
5661
yield return ObjectBaseCommonContextMenuItems.AddToJournal(simulations, _container);
5762

5863
yield return createStartParameterIdentificationMenuBarItem(simulations);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System.Collections.Generic;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using MoBi.Assets;
5+
using MoBi.Core;
6+
using MoBi.Core.Commands;
7+
using MoBi.Core.Domain.Model;
8+
using MoBi.Core.Events;
9+
using MoBi.Core.Extensions;
10+
using MoBi.Presentation.DTO;
11+
using MoBi.Presentation.Mappers;
12+
using MoBi.Presentation.Tasks;
13+
using MoBi.Presentation.Views;
14+
using OSPSuite.Assets;
15+
using OSPSuite.Core.Commands.Core;
16+
using OSPSuite.Core.Domain.ParameterIdentifications;
17+
using OSPSuite.Presentation.Presenters;
18+
using OSPSuite.Utility.Collections;
19+
using OSPSuite.Utility.Events;
20+
using OSPSuite.Utility.Extensions;
21+
22+
namespace MoBi.Presentation.Presenter;
23+
24+
public interface ISimulationUpdatePresenter : IDisposablePresenter,
25+
IListener<SimulationConfigurationStartedEvent>,
26+
IListener<SimulationConfigurationSucceededEvent>,
27+
IListener<SimulationConfigurationFailedEvent>
28+
{
29+
30+
/// <summary>
31+
/// Updates the given <paramref name="simulationsToUpdate"/> from their building blocks in parallel, showing per-simulation progress in a
32+
/// modal dialog. Blocks until the dialog is dismissed.
33+
/// </summary>
34+
/// <returns>The (already executed) command describing the update</returns>
35+
ICommand Start(IReadOnlyList<IMoBiSimulation> simulationsToUpdate);
36+
37+
void Cancel();
38+
}
39+
40+
public class SimulationUpdatePresenter : AbstractDisposablePresenter<ISimulationUpdateView, ISimulationUpdatePresenter>, ISimulationUpdatePresenter
41+
{
42+
private readonly IMoBiContext _context;
43+
private readonly ISimulationUpdateTask _simulationUpdateTask;
44+
private readonly ISimulationToSimulationUpdateStatusDTOMapper _statusMapper;
45+
private readonly Cache<string, SimulationUpdateStatusDTO> _statusDTOsCache = new(getKey: x => x.SimulationId);
46+
private CancellationTokenSource _cancellationTokenSource;
47+
48+
public SimulationUpdatePresenter(ISimulationUpdateView view,
49+
IMoBiContext context,
50+
ISimulationUpdateTask simulationUpdateTask,
51+
ISimulationToSimulationUpdateStatusDTOMapper statusMapper) : base(view)
52+
{
53+
_context = context;
54+
_simulationUpdateTask = simulationUpdateTask;
55+
_statusMapper = statusMapper;
56+
_view.AttachPresenter(this);
57+
}
58+
59+
public ICommand Start(IReadOnlyList<IMoBiSimulation> simulationsToUpdate)
60+
{
61+
var macroCommand = new MoBiMacroCommand
62+
{
63+
ObjectType = ObjectTypes.Simulation,
64+
CommandType = AppConstants.Commands.UpdateCommand
65+
};
66+
67+
_context.PublishEvent(new ClearNotificationsEvent(MessageOrigin.Simulation));
68+
69+
_statusDTOsCache.Clear();
70+
_statusDTOsCache.AddRange(simulationsToUpdate.MapAllUsing(_statusMapper));
71+
_view.BindTo(_statusDTOsCache);
72+
73+
_cancellationTokenSource = new CancellationTokenSource();
74+
75+
_simulationUpdateTask
76+
.ConfigureSimulationsInParallel(simulationsToUpdate, _cancellationTokenSource.Token)
77+
.ContinueWith(configurationTask => applyConfigurations(configurationTask, macroCommand), TaskScheduler.FromCurrentSynchronizationContext());
78+
79+
_view.Caption = AppConstants.Captions.UpdatingSimulation;
80+
_view.Display();
81+
82+
return macroCommand;
83+
}
84+
85+
public void Cancel() => _cancellationTokenSource?.Cancel();
86+
87+
public void Handle(SimulationConfigurationStartedEvent eventToHandle) => updateStatus(eventToHandle.Simulation.Id, RunStatus.Running);
88+
89+
public void Handle(SimulationConfigurationSucceededEvent eventToHandle) => updateStatus(eventToHandle.Simulation.Id, RunStatus.Created);
90+
91+
public void Handle(SimulationConfigurationFailedEvent eventToHandle) => updateStatus(eventToHandle.Simulation.Id, RunStatus.Faulted);
92+
93+
private void applyConfigurations(Task<IReadOnlyList<ModelCreationAndValidationResult>> configurationTask, MoBiMacroCommand macroCommand)
94+
{
95+
try
96+
{
97+
if (configurationTask.Status == TaskStatus.RanToCompletion)
98+
configurationTask.Result.Each(result => applyConfiguration(result, macroCommand));
99+
}
100+
finally
101+
{
102+
macroCommand.Description = AppConstants.Commands.ConfigureSimulationsDescription(macroCommand.Count);
103+
_view.ShowCompleted();
104+
}
105+
}
106+
107+
private void applyConfiguration(ModelCreationAndValidationResult result, MoBiMacroCommand macroCommand)
108+
{
109+
var command = createCommand(result);
110+
macroCommand.Add(command);
111+
112+
if (!(command is MoBiEmptyCommand))
113+
updateStatus(result.Simulation.Id, RunStatus.RanToCompletion);
114+
else
115+
{
116+
var canceledBeforeConfiguring = !result.WasConfigured && _cancellationTokenSource.IsCancellationRequested;
117+
updateStatus(result.Simulation.Id, canceledBeforeConfiguring ? RunStatus.Canceled : RunStatus.Faulted);
118+
}
119+
}
120+
121+
private ICommand createCommand(ModelCreationAndValidationResult result)
122+
{
123+
if (!result.IsValid)
124+
return new MoBiEmptyCommand();
125+
126+
var updateSimulationCommand = new UpdateSimulationCommand(result.Simulation, result.CreationResult.Model, result.CreationResult.SimulationBuilder.EntitySources, result.ClonedConfiguration);
127+
updateSimulationCommand.RunCommand(_context);
128+
return updateSimulationCommand;
129+
}
130+
131+
private void updateStatus(string simulationId, RunStatus status)
132+
{
133+
if (!_statusDTOsCache.Contains(simulationId))
134+
return;
135+
136+
var statusDTO = _statusDTOsCache[simulationId];
137+
statusDTO.Status = status;
138+
_view.RefreshData();
139+
}
140+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Collections.Generic;
2+
using MoBi.Core.Domain.Model;
3+
using MoBi.Presentation.Presenter;
4+
using OSPSuite.Core.Commands.Core;
5+
6+
namespace MoBi.Presentation.Tasks;
7+
8+
public interface IParallelSimulationUpdateTask
9+
{
10+
ICommand UpdateSimulations(IReadOnlyList<IMoBiSimulation> simulationsToUpdate);
11+
}
12+
13+
public class ParallelSimulationUpdateTask : IParallelSimulationUpdateTask
14+
{
15+
private readonly IMoBiContext _context;
16+
private readonly ISimulationUpdateTask _simulationUpdateTask;
17+
18+
public ParallelSimulationUpdateTask(IMoBiContext context, ISimulationUpdateTask simulationUpdateTask)
19+
{
20+
_context = context;
21+
_simulationUpdateTask = simulationUpdateTask;
22+
}
23+
24+
public ICommand UpdateSimulations(IReadOnlyList<IMoBiSimulation> simulationsToUpdate)
25+
{
26+
if (simulationsToUpdate.Count == 1)
27+
return _simulationUpdateTask.UpdateSimulation(simulationsToUpdate[0]);
28+
29+
using (var presenter = _context.Resolve<ISimulationUpdatePresenter>())
30+
{
31+
return presenter.Start(simulationsToUpdate);
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)