diff --git a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep.schema.json b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep.schema.json
index 2bf46e0..86ead1c 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep.schema.json
+++ b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep.schema.json
@@ -13,7 +13,7 @@
],
"source": {
"assembly": "OutWit.Controller.Sweep.Model",
- "version": "2.0.0"
+ "version": "2.1.0"
},
"generator": "OutWit.Cloud.Documents.Generator 1.0.0"
},
diff --git a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.hpp b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.hpp
index 59279db..54706b3 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.hpp
+++ b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.hpp
@@ -1,4 +1,4 @@
-// Generated by OutWit.Cloud.Documents.Generator 1.0.0 from OutWit.Controller.Sweep.Model 2.0.0.
+// Generated by OutWit.Cloud.Documents.Generator 1.0.0 from OutWit.Controller.Sweep.Model 2.1.0.
// Do not edit: regenerate from the model assembly.
//
// OmnibusCloud job document vocabulary: sweep
diff --git a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.py b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.py
index de860b2..d4d1c5d 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.py
+++ b/Sweep/OutWit.Controller.Sweep.Model/Documents/sweep_documents.py
@@ -1,4 +1,4 @@
-# Generated by OutWit.Cloud.Documents.Generator 1.0.0 from OutWit.Controller.Sweep.Model 2.0.0.
+# Generated by OutWit.Cloud.Documents.Generator 1.0.0 from OutWit.Controller.Sweep.Model 2.1.0.
# Do not edit: regenerate from the model assembly.
#
# OmnibusCloud job document vocabulary: sweep
diff --git a/Sweep/OutWit.Controller.Sweep.Model/OutWit.Controller.Sweep.Model.csproj b/Sweep/OutWit.Controller.Sweep.Model/OutWit.Controller.Sweep.Model.csproj
index 393323a..49c027f 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/OutWit.Controller.Sweep.Model.csproj
+++ b/Sweep/OutWit.Controller.Sweep.Model/OutWit.Controller.Sweep.Model.csproj
@@ -5,7 +5,7 @@
enableSweep
- 2.0.0
+ 2.1.0Shared data types of the Sweep orchestration controller: a parameter study as data for any solver family - the parameters, the variant table and exactly one family block (a CalculiX template or deck set, an OpenFOAM case) - the immutable plan with its chunk schedule, the cursor state carried across chunks, and the manifest of everything harvested so far, each row the sweep's verdict plus the node's own result. The sweep state and its result index publish a document vocabulary (sweep.state@2) for non-.NET initiators.sweep;parameter-study;doe;calculix;openfoam;witengine;witcloud;controller;data-types
diff --git a/Sweep/OutWit.Controller.Sweep.Model/README.md b/Sweep/OutWit.Controller.Sweep.Model/README.md
index e435f94..7d37abc 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/README.md
+++ b/Sweep/OutWit.Controller.Sweep.Model/README.md
@@ -13,8 +13,13 @@ family:
`SweepCalculiXDeckData`), the mesh size, the thread policy, the extraction
request;
- `OpenFOAM` (`FoamCaseData` from `OutWit.Controller.OpenFOAM.Model`) - the
- case every variant runs with its own token values.
-- `SweepPlanData` - the validated study and its chunk schedule, immutable.
+ case every variant runs with its own token values, or, in a case set,
+ what every case shares.
+- `SweepOpenFOAMSetData` - the cases of an OpenFOAM case set, a job input
+ beside the study: every variant's own ready case (`SweepOpenFOAMCaseData`:
+ its name, its tree, its recipe, its cell count and solver class).
+- `SweepPlanData` - the validated study, its case set when it has one, and
+ its chunk schedule, immutable.
- `SweepStateData` - the cursor carried across chunks: the next chunk, the
counts by outcome, the latest manifest blob and the result index
(`SweepResultIndexEntryData`: variant, outcome, label, artifacts by kind -
diff --git a/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMCaseData.cs b/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMCaseData.cs
new file mode 100644
index 0000000..f56878f
--- /dev/null
+++ b/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMCaseData.cs
@@ -0,0 +1,82 @@
+using MemoryPack;
+using OutWit.Common.Abstract;
+using OutWit.Common.Collections;
+using OutWit.Common.Values;
+using OutWit.Controller.OpenFOAM.Model;
+
+namespace OutWit.Controller.Sweep.Model;
+
+///
+/// One variant's own complete case in an OpenFOAM case set (cases prepared
+/// elsewhere - a geometry each, meshed or meshing - no tokens): its name, its
+/// tree as file references, its recipe, and its mesh size and solver class
+/// for work estimation. Everything a case set shares - threads, responses,
+/// artifacts - stays in the study's OpenFOAM block.
+///
+[MemoryPackable]
+// Explicit MemoryPackOrder pins the wire layout to the declaration order - append new members at the END only (default MemoryPack mode rejects payloads with unknown members).
+public sealed partial class SweepOpenFOAMCaseData : ModelBase
+{
+ #region Model Base
+
+ public override bool Is(ModelBase modelBase, double tolerance = DEFAULT_TOLERANCE)
+ {
+ if (modelBase is not SweepOpenFOAMCaseData data)
+ return false;
+
+ return VariantIndex.Is(data.VariantIndex)
+ && Name.Is(data.Name)
+ && BaseFiles.IsSequence(data.BaseFiles, tolerance)
+ && Recipe.Check(data.Recipe)
+ && CellCount.Is(data.CellCount)
+ && SolverClass.Is(data.SolverClass);
+ }
+
+ public override SweepOpenFOAMCaseData Clone()
+ {
+ return new SweepOpenFOAMCaseData
+ {
+ VariantIndex = VariantIndex,
+ Name = Name,
+ BaseFiles = BaseFiles.Select(file => file.Clone()).ToList(),
+ Recipe = Recipe?.Clone(),
+ CellCount = CellCount,
+ SolverClass = SolverClass
+ };
+ }
+
+ public override string ToString()
+ {
+ return $"variant #{VariantIndex}: case '{Name}', {Recipe?.Application ?? "?"}, {CellCount} cells, {BaseFiles.Count} file(s)";
+ }
+
+ #endregion
+
+ #region Properties
+
+ /// The variant this case is.
+ [MemoryPackOrder(0)]
+ public int VariantIndex { get; set; }
+
+ /// The case's name as the user knows it (its folder in the set); the variant's label.
+ [MemoryPackOrder(1)]
+ public string Name { get; set; } = string.Empty;
+
+ /// The case tree, one reference per file; nothing is templated.
+ [MemoryPackOrder(2)]
+ public List BaseFiles { get; set; } = [];
+
+ /// The case's own allow-listed recipe.
+ [MemoryPackOrder(3)]
+ public FoamRecipeData? Recipe { get; set; }
+
+ /// Cell count of the case, for work estimation; 0 falls back to the study block's.
+ [MemoryPackOrder(4)]
+ public long CellCount { get; set; }
+
+ /// Solver class of the case, for work estimation; empty falls back to the study block's.
+ [MemoryPackOrder(5)]
+ public string SolverClass { get; set; } = string.Empty;
+
+ #endregion
+}
diff --git a/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMSetData.cs b/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMSetData.cs
new file mode 100644
index 0000000..96fd3d5
--- /dev/null
+++ b/Sweep/OutWit.Controller.Sweep.Model/SweepOpenFOAMSetData.cs
@@ -0,0 +1,52 @@
+using MemoryPack;
+using OutWit.Common.Abstract;
+using OutWit.Common.Collections;
+
+namespace OutWit.Controller.Sweep.Model;
+
+///
+/// The cases of an OpenFOAM case-set study, one per variant: the second input
+/// of the SweepOpenFOAMSet script beside the study, whose OpenFOAM
+/// block then carries only what every case shares (threads, responses,
+/// artifacts) and whose variants carry no values. A separate input, not a
+/// member of the study: the study's layout stays what every released host
+/// reads, and a host without case sets refuses the unknown script by name
+/// instead of every study.
+///
+[MemoryPackable]
+// Explicit MemoryPackOrder pins the wire layout to the declaration order - append new members at the END only (default MemoryPack mode rejects payloads with unknown members).
+public sealed partial class SweepOpenFOAMSetData : ModelBase
+{
+ #region Model Base
+
+ public override bool Is(ModelBase modelBase, double tolerance = DEFAULT_TOLERANCE)
+ {
+ if (modelBase is not SweepOpenFOAMSetData data)
+ return false;
+
+ return Cases.IsSequence(data.Cases, tolerance);
+ }
+
+ public override SweepOpenFOAMSetData Clone()
+ {
+ return new SweepOpenFOAMSetData
+ {
+ Cases = Cases.Select(item => item.Clone()).ToList()
+ };
+ }
+
+ public override string ToString()
+ {
+ return $"OpenFOAM case set: {Cases.Count} case(s)";
+ }
+
+ #endregion
+
+ #region Properties
+
+ /// Every variant's own case.
+ [MemoryPackOrder(0)]
+ public List Cases { get; set; } = [];
+
+ #endregion
+}
diff --git a/Sweep/OutWit.Controller.Sweep.Model/SweepPlanData.cs b/Sweep/OutWit.Controller.Sweep.Model/SweepPlanData.cs
index 1e7625f..581ce12 100644
--- a/Sweep/OutWit.Controller.Sweep.Model/SweepPlanData.cs
+++ b/Sweep/OutWit.Controller.Sweep.Model/SweepPlanData.cs
@@ -7,8 +7,9 @@ namespace OutWit.Controller.Sweep.Model;
///
/// The validated, immutable execution plan of one sweep: the options as
-/// submitted and the chunk schedule computed up front. Mutable progress lives
-/// in , never here.
+/// submitted, the case set when the study is one, and the chunk schedule
+/// computed up front. Mutable progress lives in ,
+/// never here.
///
[MemoryPackable]
// Explicit MemoryPackOrder pins the wire layout to the declaration order - append new members at the END only (default MemoryPack mode rejects payloads with unknown members).
@@ -22,7 +23,8 @@ public override bool Is(ModelBase modelBase, double tolerance = DEFAULT_TOLERANC
return false;
return Options.Check(plan.Options)
- && ChunkSizes.Is(plan.ChunkSizes);
+ && ChunkSizes.Is(plan.ChunkSizes)
+ && OpenFOAMSet.Check(plan.OpenFOAMSet);
}
public override SweepPlanData Clone()
@@ -30,7 +32,8 @@ public override SweepPlanData Clone()
return new SweepPlanData
{
Options = Options?.Clone(),
- ChunkSizes = [.. ChunkSizes]
+ ChunkSizes = [.. ChunkSizes],
+ OpenFOAMSet = OpenFOAMSet?.Clone()
};
}
@@ -51,5 +54,9 @@ public override string ToString()
[MemoryPackOrder(1)]
public List ChunkSizes { get; set; } = [];
+ /// The cases of an OpenFOAM case-set study, as submitted; null for every other study.
+ [MemoryPackOrder(2)]
+ public SweepOpenFOAMSetData? OpenFOAMSet { get; set; }
+
#endregion
}
diff --git a/Sweep/OutWit.Controller.Sweep.Scripts/OutWit.Controller.Sweep.Scripts.csproj b/Sweep/OutWit.Controller.Sweep.Scripts/OutWit.Controller.Sweep.Scripts.csproj
index 104643c..f3d5d8f 100644
--- a/Sweep/OutWit.Controller.Sweep.Scripts/OutWit.Controller.Sweep.Scripts.csproj
+++ b/Sweep/OutWit.Controller.Sweep.Scripts/OutWit.Controller.Sweep.Scripts.csproj
@@ -4,8 +4,8 @@
Sweep
- 2.0.0
- Bundled .wit scripts for the OutWit.Controller.Sweep controller - one chunked parameter-study job per solver family: SweepCalculiX (Ccx.Solve) and SweepOpenFOAM (Foam.Run).
+ 2.1.0
+ Bundled .wit scripts for the OutWit.Controller.Sweep controller - one chunked parameter-study job per solver family: SweepCalculiX (Ccx.Solve), SweepOpenFOAM (Foam.Run) and SweepOpenFOAMSet (Foam.Run over a case set, every variant its own case).sweep;parameter-study;scripts;calculix;openfoam;witengine;witcloud;controller
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Activities/SweepOpenFOAMSetTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Activities/SweepOpenFOAMSetTests.cs
new file mode 100644
index 0000000..6ddd4b6
--- /dev/null
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Activities/SweepOpenFOAMSetTests.cs
@@ -0,0 +1,244 @@
+using MemoryPack;
+using Microsoft.Extensions.DependencyInjection;
+using OutWit.Controller.OpenFOAM.Model;
+using OutWit.Controller.OpenFOAM.Runtime;
+using OutWit.Controller.OpenFOAM.Tests.Utils;
+using OutWit.Controller.Sweep.Model;
+using OutWit.Controller.Sweep.Tests.Mock;
+using OutWit.Controller.Sweep.Tests.Utils;
+using OutWit.Engine.Interfaces;
+using OutWit.Engine.Sdk;
+
+namespace OutWit.Controller.Sweep.Tests.Activities;
+
+///
+/// The OpenFOAM case set through the engine: the bundled SweepOpenFOAMSet.wit
+/// over Foam.Run against the fake kit runs every variant on its own case -
+/// its own tree, its own recipe - with the study's shared threads, responses
+/// and artifacts, every outcome a row labelled by its case; the plan refuses
+/// a set whose case breaks the OpenFOAM rules, naming the case, and a set
+/// beside a CalculiX study, before any node sees a task.
+///
+[TestFixture]
+[NonParallelizable]
+public class SweepOpenFOAMSetTests
+{
+ #region Fields
+
+ private string m_blobStoragePath = null!;
+ private SweepTestBlobService m_blobService = null!;
+ private string m_script = null!;
+ private FakeKit m_kit = null!;
+ private IWitEngine m_engine = null!;
+ private string? m_previousKitPath;
+
+ #endregion
+
+ #region Setup
+
+ [OneTimeSetUp]
+ public void Setup()
+ {
+ var solutionRoot = SweepTestPaths.FindSolutionRoot();
+ if (solutionRoot == null)
+ Assert.Ignore("Solution root not found");
+
+ var scriptPath = SweepTestPaths.GetScriptPath(solutionRoot, "SweepOpenFOAMSet");
+ if (!File.Exists(scriptPath))
+ Assert.Ignore($"SweepOpenFOAMSet.wit not found at {scriptPath}");
+
+ m_script = File.ReadAllText(scriptPath);
+
+ var controllersPath = SweepTestPaths.FindControllersPath();
+ if (controllersPath == null)
+ Assert.Ignore("@Controllers not found");
+
+ var fakeFoam = OpenFOAMTestPaths.FindFakeFoamPath(solutionRoot);
+ if (fakeFoam == null)
+ Assert.Ignore("fake-foam not built");
+
+ if (Path.GetTempPath().Contains(' ') && !OperatingSystem.IsWindows())
+ Assert.Ignore("the temp path contains a space; OpenFOAM strips whitespace from paths, so the controller refuses it by design");
+
+ m_kit = FakeKit.Create(fakeFoam, "blockMesh", "icoFoam");
+ m_previousKitPath = Environment.GetEnvironmentVariable(FoamKitResolver.ENV_KIT_PATH);
+ Environment.SetEnvironmentVariable(FoamKitResolver.ENV_KIT_PATH, m_kit.Root);
+
+ m_blobStoragePath = Path.Combine(Path.GetTempPath(), $"witcloud_sweep_foam_set_{Guid.NewGuid():N}");
+ m_blobService = new SweepTestBlobService(m_blobStoragePath);
+
+ WitEngineNodeSdk.Instance.Reload(
+ useIsolatedContext: false,
+ moduleFolder: controllersPath,
+ configureServices: services => services.AddSingleton(m_blobService));
+
+ m_engine = WitEngineSdk.Instance;
+ m_engine.Reload(
+ useIsolatedContext: false,
+ logger: null,
+ moduleFolder: controllersPath,
+ configureServices: services =>
+ {
+ services.AddSingleton(m_blobService);
+ services.AddSingleton(new SweepTestNodesManager(WitEngineNodeSdk.Instance));
+ });
+ }
+
+ [OneTimeTearDown]
+ public void TearDown()
+ {
+ Environment.SetEnvironmentVariable(FoamKitResolver.ENV_KIT_PATH, m_previousKitPath);
+ m_kit?.Dispose();
+
+ if (m_blobStoragePath != null && Directory.Exists(m_blobStoragePath))
+ Directory.Delete(m_blobStoragePath, recursive: true);
+ }
+
+ #endregion
+
+ #region Tools
+
+ private FoamFileRefData BlobFile(string relativePath, string text)
+ {
+ return new FoamFileRefData
+ {
+ RelativePath = relativePath,
+ BlobId = m_blobService.AddText(text),
+ Sha256 = "n/a",
+ Size = text.Length
+ };
+ }
+
+ ///
+ /// A ready case whose fake solver is driven by its own system/fake - the
+ /// set's cases differ in content, not in substituted values - and whose
+ /// extra file may carry what the node refuses.
+ ///
+ private SweepOpenFOAMCaseData ReadyCase(int variantIndex, string name, string fake, string pressure = "internalField uniform 0;\n")
+ {
+ return new SweepOpenFOAMCaseData
+ {
+ VariantIndex = variantIndex,
+ Name = name,
+ BaseFiles =
+ [
+ BlobFile("system/controlDict", "FoamFile { object controlDict; }\napplication icoFoam;\nendTime 3;\n"),
+ BlobFile("system/fvSchemes", "ddtSchemes { default Euler; }\n"),
+ BlobFile("system/fvSolution", "solvers { }\n"),
+ BlobFile("system/fake", fake),
+ BlobFile("0/U", "internalField uniform (0 0 0);\n"),
+ BlobFile("0/p", pressure)
+ ],
+ Recipe = new FoamRecipeData
+ {
+ Application = "icoFoam",
+ Steps = [new FoamStepData { Utility = "blockMesh" }, new FoamStepData { Utility = "icoFoam" }]
+ },
+ CellCount = 400L * (variantIndex + 1),
+ SolverClass = "incompressible-transient"
+ };
+ }
+
+ private static SweepOptionsData Study(int variants)
+ {
+ return new SweepOptionsData
+ {
+ Variants = Enumerable.Range(0, variants).Select(index => new SweepVariantData { VariantIndex = index }).ToList(),
+ FirstChunkSize = 2,
+ MaxChunkSize = 2,
+ OpenFOAM = new FoamCaseData
+ {
+ Threads = 1,
+ ArtifactPolicy = new FoamArtifactPolicyData { Times = FoamArtifactTimes.Latest, Logs = true }
+ }
+ };
+ }
+
+ #endregion
+
+ #region Script Tests
+
+ [Test]
+ public void TheBundledScriptCompilesTest()
+ {
+ var job = m_engine.Compile(m_script);
+
+ Assert.That(job, Is.Not.Null);
+ var variables = job.Variables.Select(variable => variable.Name).ToList();
+ Assert.That(variables, Is.SupersetOf(new[] { "opts", "cases", "plan", "state", "chunks", "tasks", "wave", "manifest" }));
+ }
+
+ #endregion
+
+ #region Study Tests
+
+ [Test]
+ public async Task EveryVariantRunsItsOwnCaseAndIsLabelledByItTest()
+ {
+ var set = new SweepOpenFOAMSetData
+ {
+ Cases =
+ [
+ ReadyCase(0, "cavity-coarse", "ITERATIONS=2\n"),
+ ReadyCase(1, "cavity-broken", "FAKE-FAIL\n"),
+ ReadyCase(2, "cavity-coded", "ITERATIONS=3\n", "internalField #codeStream { code #{ #}; };\n"),
+ ReadyCase(3, "cavity-fine", "ITERATIONS=4\n")
+ ]
+ };
+ var job = m_engine.Compile(m_script);
+
+ var status = await m_engine.ScheduleAndWaitAsync(job, Study(4), set);
+
+ Assert.That(status.Result, Is.EqualTo(WitProcessingResult.Completed), "a failed and a refused case are rows, never a failed job");
+
+ var state = job.Variables["state"].Value as SweepStateData ?? new SweepStateData();
+ Assert.That((state.SucceededCount, state.FailedCount, state.RefusedCount), Is.EqualTo((2, 1, 1)));
+
+ var manifest = MemoryPackSerializer.Deserialize(
+ await File.ReadAllBytesAsync(m_blobService.GetStoredPath(state.ManifestBlobId ?? Guid.Empty))) ?? new SweepManifestData();
+ Assert.That(manifest.Rows.Select(row => row.Outcome), Is.EqualTo(new[] { SweepOutcome.Succeeded, SweepOutcome.Failed, SweepOutcome.Refused, SweepOutcome.Succeeded }));
+ Assert.That(manifest.Rows[0].OpenFOAM?.Iterations, Is.EqualTo(2), "the coarse case's own control file");
+ Assert.That(manifest.Rows[3].OpenFOAM?.Iterations, Is.EqualTo(4), "the fine case's own control file");
+ Assert.That(manifest.Rows[2].OpenFOAM?.Rejections, Has.Some.Contains("codeStream"), "the node reads each case's own content");
+ Assert.That(manifest.Rows[0].OpenFOAM?.ArtifactBlobId, Is.Not.Null, "the study's shared artifact policy");
+
+ Assert.That(state.Results.Select(entry => entry.Label), Is.EqualTo(new[] { "cavity-coarse", "cavity-broken", "cavity-coded", "cavity-fine" }));
+ }
+
+ #endregion
+
+ #region Refusal Tests
+
+ [Test]
+ public async Task ACaseOutsideTheRulesIsRefusedByNameBeforeAnyNodeRunsTest()
+ {
+ var broken = ReadyCase(1, "cavity-fine", "ITERATIONS=2\n");
+ broken.Recipe!.Steps.Insert(0, new FoamStepData { Utility = "bash" });
+ var set = new SweepOpenFOAMSetData { Cases = [ReadyCase(0, "cavity-coarse", "ITERATIONS=2\n"), broken] };
+ var job = m_engine.Compile(m_script);
+
+ var status = await m_engine.ScheduleAndWaitAsync(job, Study(2), set);
+
+ Assert.That(status.Result, Is.Not.EqualTo(WitProcessingResult.Completed));
+ Assert.That(status.Message, Does.Contain("Case 'cavity-fine': Step 1: 'bash' is not on the allow-list."));
+ Assert.That(job.Variables["state"].Value, Is.Null, "the plan refused the set: no chunk ever ran");
+ }
+
+ [Test]
+ public async Task ACaseSetBesideACalculiXStudyIsRefusedTest()
+ {
+ var options = Study(1);
+ options.OpenFOAM = null;
+ options.CalculiX = new SweepCalculiXStudyData { Decks = [new SweepCalculiXDeckData { VariantIndex = 0, DeckBlobId = m_blobService.AddText("*NODE\n") }] };
+ var set = new SweepOpenFOAMSetData { Cases = [ReadyCase(0, "cavity-coarse", "ITERATIONS=2\n")] };
+ var job = m_engine.Compile(m_script);
+
+ var status = await m_engine.ScheduleAndWaitAsync(job, options, set);
+
+ Assert.That(status.Result, Is.Not.EqualTo(WitProcessingResult.Completed));
+ Assert.That(status.Message, Does.Contain("A case set rides only with an OpenFOAM study."));
+ Assert.That(job.Variables["state"].Value, Is.Null);
+ }
+
+ #endregion
+}
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMSetTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMSetTests.cs
new file mode 100644
index 0000000..bc4178f
--- /dev/null
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMSetTests.cs
@@ -0,0 +1,249 @@
+using System.Reflection;
+using OutWit.Controller.OpenFOAM.Model;
+using OutWit.Controller.Sweep.Families;
+using OutWit.Controller.Sweep.Model;
+using OutWit.Controller.Sweep.Tests.Mock;
+
+namespace OutWit.Controller.Sweep.Tests.Families;
+
+[TestFixture]
+public class SweepFamilyOpenFOAMSetTests
+{
+ private string m_storage = null!;
+ private SweepTestBlobService m_blobs = null!;
+
+ [SetUp]
+ public void Setup()
+ {
+ m_storage = Path.Combine(Path.GetTempPath(), $"sweep-foam-set-{Guid.NewGuid():N}");
+ m_blobs = new SweepTestBlobService(m_storage);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (Directory.Exists(m_storage))
+ Directory.Delete(m_storage, recursive: true);
+ }
+
+ #region Tools
+
+ private static FoamFileRefData File(string relativePath, bool templated = false)
+ {
+ return new FoamFileRefData { RelativePath = relativePath, BlobId = Guid.NewGuid(), Templated = templated };
+ }
+
+ private static SweepOpenFOAMCaseData Case(int variantIndex, string name, long cells = 0)
+ {
+ return new SweepOpenFOAMCaseData
+ {
+ VariantIndex = variantIndex,
+ Name = name,
+ BaseFiles = [File("system/controlDict"), File("system/blockMeshDict"), File("0/U")],
+ Recipe = new FoamRecipeData { Application = "icoFoam", Steps = [new FoamStepData { Utility = "blockMesh" }, new FoamStepData { Utility = "icoFoam" }] },
+ CellCount = cells,
+ SolverClass = cells > 0 ? "incompressible-transient" : string.Empty
+ };
+ }
+
+ private static SweepOptionsData Study(int variants)
+ {
+ return new SweepOptionsData
+ {
+ Variants = Enumerable.Range(0, variants).Select(index => new SweepVariantData { VariantIndex = index }).ToList(),
+ OpenFOAM = new FoamCaseData
+ {
+ Threads = 2,
+ Extraction = new FoamExtractionRequestData { Responses = [new FoamResponseSpecData { Name = "pRange", Kind = FoamResponseKind.FieldMinMax, Fields = ["p"] }] },
+ ArtifactPolicy = new FoamArtifactPolicyData { Times = FoamArtifactTimes.Latest, Logs = true },
+ CellCount = 900,
+ SolverClass = "incompressible-steady",
+ TimeBudgetSeconds = 600
+ }
+ };
+ }
+
+ private static SweepFamilyOpenFOAMSet Family(params SweepOpenFOAMCaseData[] cases)
+ {
+ return new SweepFamilyOpenFOAMSet(new SweepOpenFOAMSetData { Cases = [.. cases] });
+ }
+
+ #endregion
+
+ #region Selection Tests
+
+ [Test]
+ public void APlanWithACaseSetRunsOnTheSetFamilyTest()
+ {
+ var set = new SweepOpenFOAMSetData { Cases = [Case(0, "coarse")] };
+
+ Assert.That(SweepFamilies.Of(new SweepPlanData { Options = Study(1), OpenFOAMSet = set }), Is.TypeOf());
+ Assert.That(SweepFamilies.Of(new SweepPlanData { Options = Study(1) }), Is.TypeOf());
+ Assert.That(SweepFamilies.For(SweepFamily.OpenFOAM, set), Is.TypeOf());
+
+ var family = SweepFamilies.For(SweepFamily.OpenFOAM, set);
+ Assert.That((family.Family, family.TaskType, family.ScriptName), Is.EqualTo((SweepFamily.OpenFOAM, typeof(FoamTaskData), "SweepOpenFOAMSet")));
+ }
+
+ #endregion
+
+ #region Validation Tests
+
+ [Test]
+ public async Task ASetWithACaseForEveryVariantIsAcceptedTest()
+ {
+ var family = Family(Case(0, "coarse", 400), Case(1, "fine", 1600));
+
+ Assert.That(await family.ValidateAsync(Study(2), m_blobs), Is.Empty);
+ }
+
+ [Test]
+ public async Task TheStudyBlockOfASetCarriesOnlyWhatTheCasesShareTest()
+ {
+ var options = Study(1);
+ options.OpenFOAM!.BaseFiles = [File("system/controlDict")];
+ options.OpenFOAM.Recipe = new FoamRecipeData { Application = "icoFoam", Steps = [new FoamStepData { Utility = "icoFoam" }] };
+ options.Parameters = [new SweepParameterData { Name = "U", Token = "{{oc1}}" }];
+
+ var findings = await Family(Case(0, "coarse")).ValidateAsync(options, m_blobs);
+
+ Assert.That(findings, Is.EqualTo(new[]
+ {
+ "A case set carries every case's files in the set; the study's OpenFOAM block names none.",
+ "A case set carries every case's recipe in the set; the study's OpenFOAM block names none.",
+ "A case set declares no parameters: every case runs as it is."
+ }));
+ }
+
+ [Test]
+ public async Task EveryVariantHasExactlyOneCaseTest()
+ {
+ Assert.That(await Family(Case(0, "a")).ValidateAsync(Study(2), m_blobs), Is.EqualTo(new[] { "Variant #1 has no case in the case set." }));
+ Assert.That(await Family(Case(0, "a"), Case(1, "b"), Case(1, "c")).ValidateAsync(Study(2), m_blobs), Is.EqualTo(new[] { "Variant #1 has 2 cases in the case set." }));
+ Assert.That(await Family(Case(0, "a"), Case(1, "b"), Case(5, "c")).ValidateAsync(Study(2), m_blobs),
+ Is.EqualTo(new[] { "The case set carries a case for variant #5, which the variant table does not have." }));
+ }
+
+ [Test]
+ public async Task EachCaseIsCheckedByTheOpenFOAMRulesUnderItsNameTest()
+ {
+ var broken = Case(1, "fine");
+ broken.BaseFiles.Add(File("processor0/0/U"));
+ broken.Recipe!.Steps.Insert(0, new FoamStepData { Utility = "bash" });
+ var unnamed = Case(2, string.Empty);
+ unnamed.Recipe = null;
+
+ var findings = await Family(Case(0, "coarse"), broken, unnamed).ValidateAsync(Study(3), m_blobs);
+
+ Assert.That(findings, Has.Some.StartsWith("Case 'fine': processor0/0/U:"));
+ Assert.That(findings, Has.Some.EqualTo("Case 'fine': Step 1: 'bash' is not on the allow-list."));
+ Assert.That(findings, Has.Some.StartsWith("Case #2: "), "a case without a name is named by its variant");
+ Assert.That(findings, Has.None.StartsWith("Case 'coarse'"));
+ }
+
+ [Test]
+ public async Task ACaseOfASetSubstitutesNothingTest()
+ {
+ var templated = Case(0, "coarse");
+ templated.BaseFiles[2].Templated = true;
+
+ var findings = await Family(templated).ValidateAsync(Study(1), m_blobs);
+
+ Assert.That(findings, Is.EqualTo(new[] { "Case 'coarse': 0/U is marked templated, but a case set substitutes nothing." }));
+ }
+
+ [Test]
+ public async Task AResponseOfASetCarriesNoTokenTest()
+ {
+ var options = Study(1);
+ options.OpenFOAM!.Extraction!.Responses.Add(new FoamResponseSpecData
+ {
+ Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs, Patches = ["lid"],
+ Parameters = [new FoamNamedValueData { Name = "magUInf", Value = "{{oc1}}" }]
+ });
+
+ var findings = await Family(Case(0, "coarse")).ValidateAsync(options, m_blobs);
+
+ Assert.That(findings, Is.EqualTo(new[] { "Response 'coeffs': token {{oc1}} is not declared by the study." }));
+ }
+
+ [Test]
+ public async Task ASetWithoutTheStudyBlockIsRefusedTest()
+ {
+ var options = Study(1);
+ options.OpenFOAM = null;
+
+ Assert.That(await Family(Case(0, "coarse")).ValidateAsync(options, m_blobs), Is.EqualTo(new[] { "The study carries no OpenFOAM block." }));
+ }
+
+ #endregion
+
+ #region Task Tests
+
+ [Test]
+ public async Task EachTaskIsItsOwnCaseWithWhatTheSetSharesTest()
+ {
+ var options = Study(2);
+ var coarse = Case(0, "coarse");
+ var fine = Case(1, "fine", 1600);
+ var family = Family(fine, coarse);
+ var uploadsBefore = Directory.Exists(m_storage) ? Directory.GetFiles(m_storage).Length : 0;
+
+ var tasks = (await family.MakeTasksAsync(options, options.Variants, m_blobs)).Cast().ToList();
+
+ Assert.That(tasks.Select(task => task.VariantIndex), Is.EqualTo(new[] { 0, 1 }), "table order, whatever the set's order");
+ Assert.That(tasks.All(task => task.Substitutions.Count == 0), Is.True);
+
+ var block = options.OpenFOAM!;
+ var expected = new FoamCaseData
+ {
+ BaseFiles = fine.BaseFiles,
+ Recipe = fine.Recipe,
+ Threads = block.Threads,
+ Extraction = block.Extraction,
+ ArtifactPolicy = block.ArtifactPolicy,
+ CellCount = 1600,
+ SolverClass = "incompressible-transient",
+ TimeBudgetSeconds = block.TimeBudgetSeconds
+ };
+ Assert.That(tasks[1].Case?.Is(expected), Is.True, "the case's tree, recipe and size; the block's threads, responses, artifacts and budget");
+
+ var fallback = tasks[0].Case ?? new FoamCaseData();
+ Assert.That((fallback.CellCount, fallback.SolverClass), Is.EqualTo((900L, "incompressible-steady")), "a case without a size falls back to the block's");
+ Assert.That(fallback.BaseFiles.Select(file => file.BlobId), Is.EqualTo(coarse.BaseFiles.Select(file => file.BlobId)));
+
+ var uploadsAfter = Directory.Exists(m_storage) ? Directory.GetFiles(m_storage).Length : 0;
+ Assert.That(uploadsAfter, Is.EqualTo(uploadsBefore), "a set chunk is metadata only: nothing is uploaded");
+ }
+
+ [Test]
+ public void EveryMemberOfTheNodesCaseIsPlacedTest()
+ {
+ // The task's case is composed member by member from the set's case
+ // and the study's block: a member FoamCaseData gains must be placed
+ // in SweepFamilyOpenFOAMSet.CaseOf (from one or the other) before this
+ // count moves.
+ var members = typeof(FoamCaseData).GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Count(property => property.GetCustomAttributes(inherit: false).Any(attribute => attribute.GetType().Name == "MemoryPackOrderAttribute"));
+
+ Assert.That(members, Is.EqualTo(8));
+ }
+
+ #endregion
+
+ #region Row Tests
+
+ [Test]
+ public void ARowOfASetIsAnOpenFOAMRowTest()
+ {
+ var result = new FoamResultData { VariantIndex = 1, ArtifactBlobId = Guid.NewGuid(), ArtifactBytes = 2048 };
+ var family = Family(Case(1, "fine"));
+
+ var row = family.ToRows(new object[] { result }).Single();
+
+ Assert.That(row.OpenFOAM, Is.SameAs(result));
+ Assert.That(family.ArtifactsOf(row).Single().Kind, Is.EqualTo(SweepArtifactKind.OpenFOAMCase));
+ }
+
+ #endregion
+}
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepDataRoundTripTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepDataRoundTripTests.cs
index b0d0a4f..a29c2d9 100644
--- a/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepDataRoundTripTests.cs
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepDataRoundTripTests.cs
@@ -44,6 +44,24 @@ private static SweepOptionsData OpenFOAMStudy()
};
}
+ private static SweepOpenFOAMCaseData SetCase(int variantIndex, string name, long cells)
+ {
+ return new SweepOpenFOAMCaseData
+ {
+ VariantIndex = variantIndex,
+ Name = name,
+ BaseFiles = [new FoamFileRefData { RelativePath = "system/blockMeshDict", BlobId = Guid.NewGuid(), Sha256 = "0a", Size = 812 }],
+ Recipe = new FoamRecipeData { Application = "icoFoam", Steps = [new FoamStepData { Utility = "blockMesh" }, new FoamStepData { Utility = "icoFoam" }] },
+ CellCount = cells,
+ SolverClass = "incompressible-transient"
+ };
+ }
+
+ private static SweepOpenFOAMSetData CaseSet()
+ {
+ return new SweepOpenFOAMSetData { Cases = [SetCase(0, "cavity-coarse", 400), SetCase(1, "cavity-fine", 1600)] };
+ }
+
private static T RoundTrip(T value)
{
var restored = MemoryPackSerializer.Deserialize(MemoryPackSerializer.Serialize(value));
@@ -169,5 +187,55 @@ public void APlanSurvivesARoundTripTest()
Assert.That(RoundTrip(plan).Is(plan), Is.True);
}
+ [Test]
+ public void APlanWithItsCaseSetSurvivesARoundTripTest()
+ {
+ var plan = new SweepPlanData { Options = OpenFOAMStudy(), ChunkSizes = [2], OpenFOAMSet = CaseSet() };
+
+ var restored = RoundTrip(plan);
+
+ Assert.That(restored.Is(plan), Is.True);
+ Assert.That(restored.OpenFOAMSet?.Cases.Select(item => (item.VariantIndex, item.Name, item.CellCount)),
+ Is.EqualTo(new[] { (0, "cavity-coarse", 400L), (1, "cavity-fine", 1600L) }));
+ Assert.That(plan.OpenFOAMSet?.ToString(), Is.EqualTo("OpenFOAM case set: 2 case(s)"));
+ }
+
+ [Test]
+ public void AClonedCaseSetIsEqualAndIndependentTest()
+ {
+ var set = CaseSet();
+ var clone = set.Clone();
+
+ Assert.That(clone.Is(set), Is.True);
+
+ clone.Cases[1].Name = "cavity-finer";
+ clone.Cases[0].BaseFiles[0].Size = 1;
+ Assert.That(clone.Is(set), Is.False);
+ Assert.That(set.Cases[1].Name, Is.EqualTo("cavity-fine"));
+ Assert.That(set.Cases[0].BaseFiles[0].Size, Is.EqualTo(812));
+
+ var plan = new SweepPlanData { Options = OpenFOAMStudy(), ChunkSizes = [2] };
+ var withSet = plan.Clone();
+ withSet.OpenFOAMSet = set;
+ Assert.That(withSet.Is(plan), Is.False, "the case set is part of the plan");
+ Assert.That(withSet.Clone().Is(withSet), Is.True);
+ }
+
+ [Test]
+ public void APlanAnEarlierHostWroteStillReadsTest()
+ {
+ // A job planned before the upgrade holds a two-member plan: the
+ // default MemoryPack reader takes fewer members than it knows.
+ var plan = new SweepPlanData { Options = OpenFOAMStudy(), ChunkSizes = [3, 1] };
+ var bytes = MemoryPackSerializer.Serialize(plan);
+ Assert.That((bytes[0], bytes[^1]), Is.EqualTo(((byte)3, (byte)255)), "three members, the last a null case set");
+
+ var earlier = bytes[..^1];
+ earlier[0] = 2;
+
+ var restored = MemoryPackSerializer.Deserialize(earlier);
+ Assert.That(restored?.Is(plan), Is.True);
+ }
+
#endregion
}
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepModelWireLayoutTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepModelWireLayoutTests.cs
index d3e4fda..be53d0f 100644
--- a/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepModelWireLayoutTests.cs
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Model/SweepModelWireLayoutTests.cs
@@ -21,7 +21,9 @@ namespace OutWit.Controller.Sweep.Tests.Model;
public sealed class SweepModelWireLayoutTests
{
// Field counts frozen 2026-09-24 (Sweep.Model 2.0.0, the family-neutral
- // sweep). Bump a count ONLY when appending at the end of that type.
+ // sweep); the plan's case set and its two types appended 2026-09-27
+ // (Sweep.Model 2.1.0 - the plan is written and read by the host alone).
+ // Bump a count ONLY when appending at the end of that type.
private static readonly IReadOnlyDictionary EXPECTED_FIELD_COUNTS = new Dictionary
{
[typeof(SweepArtifactData)] = 3,
@@ -29,9 +31,11 @@ public sealed class SweepModelWireLayoutTests
[typeof(SweepCalculiXStudyData)] = 6,
[typeof(SweepManifestData)] = 1,
[typeof(SweepManifestRowData)] = 4,
+ [typeof(SweepOpenFOAMCaseData)] = 6,
+ [typeof(SweepOpenFOAMSetData)] = 1,
[typeof(SweepOptionsData)] = 6,
[typeof(SweepParameterData)] = 2,
- [typeof(SweepPlanData)] = 2,
+ [typeof(SweepPlanData)] = 3,
[typeof(SweepResultIndexEntryData)] = 4,
[typeof(SweepStateData)] = 7,
[typeof(SweepVariantData)] = 2
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Utils/SweepVariantLabelTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Utils/SweepVariantLabelTests.cs
index db3c112..123e16a 100644
--- a/Sweep/OutWit.Controller.Sweep.Tests/Utils/SweepVariantLabelTests.cs
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Utils/SweepVariantLabelTests.cs
@@ -1,3 +1,4 @@
+using OutWit.Controller.OpenFOAM.Model;
using OutWit.Controller.Sweep.Model;
using OutWit.Controller.Sweep.Utils;
@@ -42,10 +43,47 @@ public void LabelIsEmptyWithoutAnIdentityTest()
CalculiX = new SweepCalculiXStudyData { Decks = [new SweepCalculiXDeckData { VariantIndex = 0, DeckBlobId = Guid.NewGuid() }] }
};
- Assert.That(SweepVariantLabel.Of(null, 0), Is.Empty);
+ Assert.That(SweepVariantLabel.Of((SweepOptionsData?)null, 0), Is.Empty);
Assert.That(SweepVariantLabel.Of(deckSet, 0), Is.Empty, "a deck-set variant has no values");
Assert.That(SweepVariantLabel.Of(deckSet, 7), Is.Empty, "an unknown variant has no label");
}
+ [Test]
+ public void ACaseSetVariantIsLabelledByItsCaseTest()
+ {
+ var plan = new SweepPlanData
+ {
+ Options = new SweepOptionsData
+ {
+ Variants = [new SweepVariantData { VariantIndex = 0 }, new SweepVariantData { VariantIndex = 1 }],
+ OpenFOAM = new FoamCaseData()
+ },
+ OpenFOAMSet = new SweepOpenFOAMSetData
+ {
+ Cases = [new SweepOpenFOAMCaseData { VariantIndex = 0, Name = "cavity-coarse" }, new SweepOpenFOAMCaseData { VariantIndex = 1 }]
+ }
+ };
+
+ Assert.That(SweepVariantLabel.Of(plan, 0), Is.EqualTo("cavity-coarse"));
+ Assert.That(SweepVariantLabel.Of(plan, 1), Is.Empty, "a case without a name falls back to the variant number");
+ Assert.That(SweepVariantLabel.Of(plan, 7), Is.Empty);
+ }
+
+ [Test]
+ public void APlanWithoutACaseSetIsLabelledByItsValuesTest()
+ {
+ var plan = new SweepPlanData
+ {
+ Options = new SweepOptionsData
+ {
+ Parameters = [new SweepParameterData { Name = "U", Token = "{{oc1}}" }],
+ Variants = [new SweepVariantData { VariantIndex = 0, Values = ["10"] }]
+ }
+ };
+
+ Assert.That(SweepVariantLabel.Of(plan, 0), Is.EqualTo("U=10"));
+ Assert.That(SweepVariantLabel.Of((SweepPlanData?)null, 0), Is.Empty);
+ }
+
#endregion
}
diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Variables/SweepWrapperWireLayoutTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Variables/SweepWrapperWireLayoutTests.cs
index e19a481..44388af 100644
--- a/Sweep/OutWit.Controller.Sweep.Tests/Variables/SweepWrapperWireLayoutTests.cs
+++ b/Sweep/OutWit.Controller.Sweep.Tests/Variables/SweepWrapperWireLayoutTests.cs
@@ -17,7 +17,8 @@ namespace OutWit.Controller.Sweep.Tests.Variables;
[TestFixture]
public sealed class SweepWrapperWireLayoutTests
{
- // Declared layouts frozen 2026-09-24 (Sweep 2.0: Sweep.Plan takes the options alone). The variable wrappers deliberately
+ // Declared layouts frozen 2026-09-24 (Sweep 2.0: Sweep.Plan takes the options alone); Sweep 2.1 appends the
+ // optional case set to Sweep.Plan and adds its variable (host-only: scripts are parsed on the host). The variable wrappers deliberately
// declare NOTHING — name and payload serialize through the engine base
// class; a property declared here would silently extend the wire.
private static readonly IReadOnlyDictionary EXPECTED_LAYOUTS = new Dictionary
@@ -25,7 +26,8 @@ public sealed class SweepWrapperWireLayoutTests
[typeof(WitVariableSweepPlan)] = [],
[typeof(WitVariableSweepState)] = [],
[typeof(WitVariableSweepOptions)] = [],
- [typeof(WitActivitySweepPlan)] = ["Options"],
+ [typeof(WitVariableSweepOpenFOAMSet)] = [],
+ [typeof(WitActivitySweepPlan)] = ["Options", "OpenFOAMSet"],
[typeof(WitActivitySweepInitState)] = ["Plan"],
[typeof(WitActivitySweepChunkCount)] = ["Plan"],
[typeof(WitActivitySweepMakeChunk)] = ["Plan", "State"],
diff --git a/Sweep/OutWit.Controller.Sweep/Activities/WitActivitySweepPlan.cs b/Sweep/OutWit.Controller.Sweep/Activities/WitActivitySweepPlan.cs
index de41a9a..bfdd949 100644
--- a/Sweep/OutWit.Controller.Sweep/Activities/WitActivitySweepPlan.cs
+++ b/Sweep/OutWit.Controller.Sweep/Activities/WitActivitySweepPlan.cs
@@ -8,8 +8,9 @@
namespace OutWit.Controller.Sweep.Activities;
///
-/// Validates the submitted study - the study itself and its family's block -
-/// and computes the immutable execution plan, chunk schedule included.
+/// Validates the submitted study - the study itself and its family's block,
+/// and the cases of an OpenFOAM case set when the script passes one - and
+/// computes the immutable execution plan, chunk schedule included.
///
[Activity("Sweep.Plan")]
[MemoryPackable]
@@ -19,7 +20,7 @@ public sealed partial class WitActivitySweepPlan : WitActivityFunction
protected override string InnerString()
{
- return $"{Options}";
+ return OpenFOAMSet == null ? $"{Options}" : $"{Options}, {OpenFOAMSet}";
}
#endregion
@@ -32,14 +33,16 @@ public override bool Is(ModelBase modelBase, double tolerance = DEFAULT_TOLERANC
return false;
return base.Is(activity, tolerance)
- && Options.Check(activity.Options);
+ && Options.Check(activity.Options)
+ && OpenFOAMSet.Check(activity.OpenFOAMSet);
}
protected override WitActivitySweepPlan InnerClone()
{
return new WitActivitySweepPlan
{
- Options = Options?.Clone() as IWitReference
+ Options = Options?.Clone() as IWitReference,
+ OpenFOAMSet = OpenFOAMSet?.Clone() as IWitReference
};
}
@@ -51,5 +54,9 @@ protected override WitActivitySweepPlan InnerClone()
[MemoryPackAllowSerialize]
public IWitReference? Options { get; init; }
+ /// Reference to the optional OpenFOAMSet argument (the cases of a case-set study); null otherwise.
+ [MemoryPackAllowSerialize]
+ public IWitReference? OpenFOAMSet { get; init; }
+
#endregion
}
diff --git a/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepHarvest.cs b/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepHarvest.cs
index aa1d083..dd442d3 100644
--- a/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepHarvest.cs
+++ b/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepHarvest.cs
@@ -82,7 +82,7 @@ protected override async Task Process(WitActivitySweepHarvest activity, IWitVari
{
VariantIndex = row.VariantIndex,
Outcome = row.Outcome,
- Label = SweepVariantLabel.Of(plan.Options, row.VariantIndex),
+ Label = SweepVariantLabel.Of(plan, row.VariantIndex),
Artifacts = family.ArtifactsOf(row).ToList()
})
.ToList()
diff --git a/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepPlan.cs b/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepPlan.cs
index 5888c1d..d297a10 100644
--- a/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepPlan.cs
+++ b/Sweep/OutWit.Controller.Sweep/Adapters/WitActivityAdapterSweepPlan.cs
@@ -31,11 +31,16 @@ protected override async Task Process(WitActivitySweepPlan activity, IWitVariabl
if (!pool.TryGetValue(activity.Options, out SweepOptionsData? options) || options == null)
throw new InvalidOperationException("Failed to get parameter 'Options'.");
- // Plan is the one validation gate: every finding of the study and of
- // its family block is named together, before any node sees a task.
- var findings = ValidateStudy(options);
+ SweepOpenFOAMSetData? caseSet = null;
+ if (activity.OpenFOAMSet != null && (!pool.TryGetValue(activity.OpenFOAMSet, out caseSet) || caseSet == null))
+ throw new InvalidOperationException("Failed to get parameter 'OpenFOAMSet'.");
+
+ // Plan is the one validation gate: every finding of the study, of
+ // its family block and of a case set is named together, before any
+ // node sees a task.
+ var findings = ValidateStudy(options, caseSet);
if (options.Family is { } family)
- findings.AddRange(await SweepFamilies.For(family).ValidateAsync(options, BlobService));
+ findings.AddRange(await SweepFamilies.For(family, caseSet).ValidateAsync(options, BlobService));
if (findings.Count > 0)
throw new InvalidOperationException($"The sweep is refused: {string.Join(" ", findings)}");
@@ -48,12 +53,13 @@ protected override async Task Process(WitActivitySweepPlan activity, IWitVariabl
var plan = new SweepPlanData
{
Options = options,
- ChunkSizes = SweepChunkPlanner.Sizes(options.FirstChunkSize, options.MaxChunkSize, options.Variants.Count, availableNodes)
+ ChunkSizes = SweepChunkPlanner.Sizes(options.FirstChunkSize, options.MaxChunkSize, options.Variants.Count, availableNodes),
+ OpenFOAMSet = caseSet
};
Logger.LogInformation(
- "Sweep plan: {Family}, {Variants} variant(s), {Nodes} eligible machine(s), chunks [{Chunks}] (client asked first {First}, max {Max})",
- options.Family, options.Variants.Count, availableNodes, string.Join(", ", plan.ChunkSizes), options.FirstChunkSize, options.MaxChunkSize);
+ "Sweep plan: {Family}{Mode}, {Variants} variant(s), {Nodes} eligible machine(s), chunks [{Chunks}] (client asked first {First}, max {Max})",
+ options.Family, caseSet == null ? string.Empty : " case set", options.Variants.Count, availableNodes, string.Join(", ", plan.ChunkSizes), options.FirstChunkSize, options.MaxChunkSize);
if (!pool.TrySetValue(activity.ReturnReference, plan))
throw new InvalidOperationException($"Failed to set return value '{activity.ReturnReference}'.");
@@ -63,9 +69,10 @@ protected override async Task Process(WitActivitySweepPlan activity, IWitVariabl
/// The family-independent rules of a study: one family block, at least one
/// variant, one value per parameter in every variant, and unique variant
/// indices - the manifest maps results by index, never positionally, so a
- /// repeated index would burn node time and come back unmappable.
+ /// repeated index would burn node time and come back unmappable. A case
+ /// set belongs to an OpenFOAM study alone.
///
- private static List ValidateStudy(SweepOptionsData options)
+ private static List ValidateStudy(SweepOptionsData options, SweepOpenFOAMSetData? caseSet)
{
var findings = new List();
@@ -85,6 +92,9 @@ private static List ValidateStudy(SweepOptionsData options)
foreach (var group in options.Variants.GroupBy(variant => variant.VariantIndex).Where(group => group.Count() > 1))
findings.Add($"Variant index {group.Key} appears {group.Count()} times; variant indices must be unique.");
+ if (caseSet != null && options.Family is { } family && family != SweepFamily.OpenFOAM)
+ findings.Add("A case set rides only with an OpenFOAM study.");
+
return findings;
}
@@ -116,15 +126,20 @@ protected override WitActivitySweepPlan CreateActivity(IWitParameter[] parameter
{
try
{
- if (parameters.Length != 1)
- throw new ArgumentException($"Expected 1 parameter(s), got {parameters.Length}.");
+ if (parameters.Length is < 1 or > 2)
+ throw new ArgumentException($"Expected 1 or 2 parameter(s), got {parameters.Length}.");
if (parameters[0] is not IWitReference options)
throw new ArgumentException("Parameter 'Options' must be a variable reference.");
+ IWitReference? caseSet = null;
+ if (parameters.Length == 2 && (caseSet = parameters[1] as IWitReference) == null)
+ throw new ArgumentException("Parameter 'OpenFOAMSet' must be a variable reference.");
+
return new WitActivitySweepPlan
{
- Options = options
+ Options = options,
+ OpenFOAMSet = caseSet
};
}
catch (Exception e)
diff --git a/Sweep/OutWit.Controller.Sweep/Families/SweepFamilies.cs b/Sweep/OutWit.Controller.Sweep/Families/SweepFamilies.cs
index 0fe5f99..8d0549f 100644
--- a/Sweep/OutWit.Controller.Sweep/Families/SweepFamilies.cs
+++ b/Sweep/OutWit.Controller.Sweep/Families/SweepFamilies.cs
@@ -6,7 +6,8 @@ namespace OutWit.Controller.Sweep.Families;
///
/// The solver families the host knows, by . A new
/// family is one entry here and one beside the
-/// others.
+/// others. An OpenFOAM case set is the OpenFOAM family's second input form:
+/// the same tasks, results and rows, its cases read from the set.
///
internal static class SweepFamilies
{
@@ -35,6 +36,20 @@ public static ISweepFamily For(SweepFamily family)
: throw new InvalidOperationException($"This Sweep host does not know the solver family {family}.");
}
+ ///
+ /// The family implementation for a study and its optional case set.
+ ///
+ /// The study's family.
+ /// The cases of an OpenFOAM case set; null for every other study.
+ /// The case-set form of the OpenFOAM family when a set is given to an OpenFOAM study; otherwise the family itself.
+ /// The host does not know the family.
+ public static ISweepFamily For(SweepFamily family, SweepOpenFOAMSetData? caseSet)
+ {
+ return caseSet != null && family == SweepFamily.OpenFOAM
+ ? new SweepFamilyOpenFOAMSet(caseSet)
+ : For(family);
+ }
+
///
/// The family a validated plan runs on.
///
@@ -45,7 +60,7 @@ public static ISweepFamily Of(SweepPlanData plan)
{
var family = plan.Options?.Family
?? throw new InvalidOperationException("The plan's study carries no single family block.");
- return For(family);
+ return For(family, plan.OpenFOAMSet);
}
#endregion
diff --git a/Sweep/OutWit.Controller.Sweep/Families/SweepFamilyOpenFOAMSet.cs b/Sweep/OutWit.Controller.Sweep/Families/SweepFamilyOpenFOAMSet.cs
new file mode 100644
index 0000000..6988b60
--- /dev/null
+++ b/Sweep/OutWit.Controller.Sweep/Families/SweepFamilyOpenFOAMSet.cs
@@ -0,0 +1,178 @@
+using System.Collections;
+using OutWit.Controller.OpenFOAM.Model;
+using OutWit.Controller.OpenFOAM.Model.Rules;
+using OutWit.Controller.Sweep.Interfaces;
+using OutWit.Controller.Sweep.Model;
+using OutWit.Engine.Interfaces;
+
+namespace OutWit.Controller.Sweep.Families;
+
+///
+/// OpenFOAM case sets: every variant its own ready case - a tree and a recipe
+/// of its own, no tokens - and the study's OpenFOAM block what the cases share
+/// (threads, responses, artifacts, the work-estimate fallbacks). The node
+/// runs each task's case as it runs a study's, so a chunk here is metadata
+/// only and the results, rows and artifacts are the OpenFOAM family's. Each
+/// case is checked with the OpenFOAM model's own rules under its name, before
+/// any node sees a task.
+///
+internal sealed class SweepFamilyOpenFOAMSet : ISweepFamily
+{
+ #region Constants
+
+ /// The bundled script of the case-set form.
+ public const string SCRIPT_NAME = "SweepOpenFOAMSet";
+
+ #endregion
+
+ #region Fields
+
+ private readonly SweepOpenFOAMSetData m_caseSet;
+
+ private readonly SweepFamilyOpenFOAM m_family = new();
+
+ #endregion
+
+ #region Constructors
+
+ ///
+ /// Creates the case-set form over the submitted cases.
+ ///
+ /// The cases, one per variant.
+ public SweepFamilyOpenFOAMSet(SweepOpenFOAMSetData caseSet)
+ {
+ m_caseSet = caseSet;
+ }
+
+ #endregion
+
+ #region Functions
+
+ ///
+ /// The case a node runs for one case of the set: the case's tree, recipe
+ /// and size, the study block's threads, responses, artifacts and budget.
+ /// Every member of is placed here, from one or
+ /// the other.
+ ///
+ /// The study's OpenFOAM block.
+ /// One case of the set.
+ /// The case as the task carries it.
+ public static FoamCaseData CaseOf(FoamCaseData block, SweepOpenFOAMCaseData item)
+ {
+ return new FoamCaseData
+ {
+ BaseFiles = item.BaseFiles,
+ Recipe = item.Recipe,
+ Threads = block.Threads,
+ Extraction = block.Extraction,
+ ArtifactPolicy = block.ArtifactPolicy,
+ CellCount = item.CellCount > 0 ? item.CellCount : block.CellCount,
+ SolverClass = string.IsNullOrEmpty(item.SolverClass) ? block.SolverClass : item.SolverClass,
+ TimeBudgetSeconds = block.TimeBudgetSeconds
+ };
+ }
+
+ #endregion
+
+ #region ISweepFamily
+
+ public Task> ValidateAsync(SweepOptionsData options, IWitBlobService blobService)
+ {
+ var findings = new List();
+ var block = options.OpenFOAM;
+ if (block == null)
+ {
+ findings.Add("The study carries no OpenFOAM block.");
+ return Task.FromResult>(findings);
+ }
+
+ // The set and the block are distinct halves: a tree or a recipe in
+ // the block would be a second case no variant runs.
+ if (block.BaseFiles.Count > 0)
+ findings.Add("A case set carries every case's files in the set; the study's OpenFOAM block names none.");
+ if (block.Recipe != null)
+ findings.Add("A case set carries every case's recipe in the set; the study's OpenFOAM block names none.");
+ if (options.Parameters.Count > 0)
+ findings.Add("A case set declares no parameters: every case runs as it is.");
+
+ ValidateCoverage(options, findings);
+
+ foreach (var item in m_caseSet.Cases)
+ {
+ var name = NameOf(item);
+ foreach (var file in item.BaseFiles.Where(file => file.Templated))
+ findings.Add($"{name}: {file.RelativePath} is marked templated, but a case set substitutes nothing.");
+
+ findings.AddRange(FoamCaseRules.Validate(CaseOf(block, item)).Select(finding => $"{name}: {finding}"));
+ }
+
+ findings.AddRange(FoamTemplating.CheckResponseCoverage([], block.Extraction));
+ return Task.FromResult>(findings);
+ }
+
+ public Task> MakeTasksAsync(SweepOptionsData options, IReadOnlyList variants, IWitBlobService blobService)
+ {
+ var block = options.OpenFOAM ?? throw new InvalidOperationException("The study carries no OpenFOAM block.");
+ var cases = m_caseSet.Cases.ToDictionary(item => item.VariantIndex);
+
+ IReadOnlyList