From cf69d5ba0051292d420d2c5af872017d6a89234b Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:58:45 +0300 Subject: [PATCH 1/4] feat(openfoam-model): 1.4.0 - the includeFunc step, and field names with dots includeFunc is the controller's second own step: one line, #includeFunc , in the functions of the node's copy of system/controlDict, so the solver measures the response while it solves. A force measured after the solve ( -postProcess) sees the walls a rotating zone (MRF) turns at rest - OpenFOAM moves them only inside the solve - and misses the torque by an order of magnitude and its sign. The recipe rules take one response name, never under MPI, with a solve after it; the case rules take only a response of the task. A response field may carry dots, as phases are named (alpha.water); never a colon, since a probe writes one file per field. Patches and names stay words. SUPPORTED-INPUTS.md says the one line the node may add, when and why. Co-Authored-By: Claude Opus 5.5 --- .../FoamStepOutcomeData.cs | 4 +- .../OutWit.Controller.OpenFOAM.Model.csproj | 2 +- .../README.md | 6 ++- .../Rules/FoamAllowList.cs | 14 ++++++- .../Rules/FoamCaseRules.cs | 31 +++++++++++++-- .../Rules/FoamRecipeRules.cs | 21 ++++++++++ .../Rules/FoamResponseRules.cs | 10 ++++- .../Model/Rules/FoamAllowListTests.cs | 12 +++++- .../Model/Rules/FoamCaseRulesTests.cs | 17 ++++++++ .../Model/Rules/FoamRecipeRulesTests.cs | 39 +++++++++++++++++++ .../Model/Rules/FoamResponseRulesTests.cs | 35 +++++++++++++++++ .../Rules/FoamSupportedInputsDocumentTests.cs | 2 + OpenFOAM/SUPPORTED-INPUTS.md | 28 +++++++++++-- 13 files changed, 205 insertions(+), 16 deletions(-) diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/FoamStepOutcomeData.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/FoamStepOutcomeData.cs index f2da401..58262f7 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/FoamStepOutcomeData.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/FoamStepOutcomeData.cs @@ -51,8 +51,8 @@ public override string ToString() /// /// MPI ranks the step ran on; 1 for a serial step; 0 for a step no - /// process ran - a decomposition step skipped on a serial run, or the - /// controller's own restore0Dir. + /// process ran - a decomposition step skipped on a serial run, or one of + /// the controller's own steps (restore0Dir, includeFunc). /// [MemoryPackOrder(1)] public int Ranks { get; set; } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/OutWit.Controller.OpenFOAM.Model.csproj b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/OutWit.Controller.OpenFOAM.Model.csproj index 7f46560..ade1001 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/OutWit.Controller.OpenFOAM.Model.csproj +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/OutWit.Controller.OpenFOAM.Model.csproj @@ -5,7 +5,7 @@ enable OpenFOAM - 1.3.1 + 1.4.0 Shared data types of the OpenFOAM case controller and the rules a case obeys: the case (base files, allow-listed recipe, response request, artifact policy), one variant's run (the case and its token values) and its outcome (per-step exit codes and times, convergence facts, the extracted response row, the artifact reference); the allow-list, recipe grammar, case-path, response, token-coverage and run-time-code rules, byte-exact substitution and the solver-class vocabulary, shared by the node, the Sweep host and client preflight. Consumed by the node controller, the Sweep orchestration controller and client applications. openfoam;cfd;case;sweep;parameter-study;witengine;witcloud;controller;data-types diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md index 7f2c221..ea715e3 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md @@ -20,9 +20,11 @@ rules both enforce: `Rules/` holds what a case may be, as code every party runs (published as [SUPPORTED-INPUTS.md](https://github.com/OmnibusCloud/Controllers/blob/main/OpenFOAM/SUPPORTED-INPUTS.md)): `FoamAllowList` -(the utilities, the solver shape, and the controller's own step +(the utilities, the solver shape, and the controller's own steps: `restore0Dir -processor`, which puts the initial fields into the processor -directories of a case meshed on its decomposed form), `FoamRecipeRules` +directories of a case meshed on its decomposed form, and +`includeFunc `, which adds a response to the solve through one line +in the node's copy of `system/controlDict`), `FoamRecipeRules` (steps and argument grammar, one step judged under a caller's name; the kit is asked through a predicate), `FoamCasePathRules` (the base tree: paths inside the case, no spaces, no case-only collisions, no leftovers diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamAllowList.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamAllowList.cs index e0d1581..f3dccc2 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamAllowList.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamAllowList.cs @@ -26,8 +26,20 @@ public static class FoamAllowList /// The one form of : into the processor directories. public const string PROCESSOR_FORM = "-processor"; + /// + /// The controller's step that adds a response to the solve + /// (includeFunc <response>): one line, #includeFunc + /// <response>, in the functions of the node's copy of + /// system/controlDict, so the solver measures the response while it + /// solves. A force measured after the solve (<solver> + /// -postProcess) sees the walls a rotating zone (MRF) turns at rest, + /// because OpenFOAM moves them only inside the solve; measured by the + /// solver, the force is the solve's own. + /// + public const string INCLUDE_FUNCTION = "includeFunc"; + /// The steps the controller does itself: no kit executable, never under MPI. - public static readonly IReadOnlyList BUILT_IN_STEPS = [RESTORE_INITIAL_FIELDS]; + public static readonly IReadOnlyList BUILT_IN_STEPS = [RESTORE_INITIAL_FIELDS, INCLUDE_FUNCTION]; /// /// The utilities, in the order they usually run: those of version 1, then diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamCaseRules.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamCaseRules.cs index 17cd770..2e9d0e4 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamCaseRules.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamCaseRules.cs @@ -2,10 +2,11 @@ namespace OutWit.Controller.OpenFOAM.Model.Rules; /// /// Everything that can be decided about a case from its data alone, before a -/// byte of it is downloaded: the base tree, the recipe, the response request. -/// The one entry the node, the Sweep host and the initiator call; what needs -/// the files' contents (token coverage, run-time code) or the materialised -/// case is checked where those exist. +/// byte of it is downloaded: the base tree, the recipe, the response request, +/// and what the recipe and the request say of each other. The one entry the +/// node, the Sweep host and the initiator call; what needs the files' +/// contents (token coverage, run-time code) or the materialised case is +/// checked where those exist. /// public static class FoamCaseRules { @@ -26,8 +27,30 @@ public static IReadOnlyList Validate(FoamCaseData? data, Func file.RelativePath).ToList())); + findings.AddRange(IncludedResponses(data.Recipe, data.Extraction)); return findings; } #endregion + + #region Tools + + // The controller's include adds the task's own responses to the solve, + // nothing else: the node's copy of controlDict gains no line the request + // did not ask for. + private static IEnumerable IncludedResponses(FoamRecipeData? recipe, FoamExtractionRequestData? extraction) + { + if (recipe == null) + yield break; + + var responses = new HashSet((extraction?.Responses ?? []).Select(response => response.Name), StringComparer.Ordinal); + for (var index = 0; index < recipe.Steps.Count; index++) + { + var step = recipe.Steps[index]; + if (step.Utility == FoamAllowList.INCLUDE_FUNCTION && step.Arguments is [var response] && !responses.Contains(response)) + yield return $"Step {index + 1}: {FoamAllowList.INCLUDE_FUNCTION} {response} names no response of this task; the step adds only the task's own responses to the solve."; + } + } + + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs index 445b486..30eea09 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs @@ -19,6 +19,8 @@ public static class FoamRecipeRules private const string DECOMPOSE = "decomposePar"; + private const string POST_PROCESS_FLAG = "-postProcess"; + /// A flag: a dash, a letter, then letters, digits or dashes. private static readonly Regex FLAG = new("^-[A-Za-z][A-Za-z0-9-]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); @@ -78,6 +80,12 @@ public static IReadOnlyList Validate(FoamRecipeData? recipe, Func before.Utility != DECOMPOSE)) findings.Add($"Step {index + 1}: {FoamAllowList.RESTORE_INITIAL_FIELDS} {FoamAllowList.PROCESSOR_FORM} needs a {DECOMPOSE} step before it."); + + // The controller's include adds a response to the solve: a solve must come after it. + if (step.Utility == FoamAllowList.INCLUDE_FUNCTION + && step.Arguments is [var response] && WORD.IsMatch(response) + && !recipe.Steps.Skip(index + 1).Any(after => IsSolve(after, recipe.Application))) + findings.Add($"Step {index + 1}: {FoamAllowList.INCLUDE_FUNCTION} {response} adds a response to the solve, and no step after it runs '{recipe.Application}' to solve."); } if (recipe.Steps.Count > 0 @@ -176,9 +184,22 @@ private static void ValidateBuiltIn(FoamStepData step, string prefix, List', one response name."); + return; + } + if (step.Arguments is not [FoamAllowList.PROCESSOR_FORM]) findings.Add($"{prefix} ({name}): the only form is '{name} {FoamAllowList.PROCESSOR_FORM}'; a serial run's initial fields travel in 0/."); } + /// A step that runs the application to solve, not its -postProcess form. + private static bool IsSolve(FoamStepData step, string application) + { + return step.Utility == application && !step.Arguments.Contains(POST_PROCESS_FLAG); + } + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamResponseRules.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamResponseRules.cs index 9dbd4e1..b22bd2a 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamResponseRules.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamResponseRules.cs @@ -28,6 +28,14 @@ public static class FoamResponseRules private static readonly Regex WORD = new("^[A-Za-z][A-Za-z0-9_]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + /// + /// A field: a word that may carry dots, as every multiphase solver names + /// its phases (alpha.water) and a region its fields. No colon: a + /// probe writes one file per field, and a Windows node has no file named + /// U:Transformed. + /// + private static readonly Regex FIELD = new("^[A-Za-z][A-Za-z0-9_.]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private static readonly Regex OPERATION = new("^[A-Za-z][A-Za-z0-9]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant); /// A dictionary entry value: words, numbers, vectors and lists in parentheses - no braces, no semicolons, no code. @@ -76,7 +84,7 @@ public static IReadOnlyList Validate(FoamExtractionRequestData? request, foreach (var patch in response.Patches.Where(patch => !WORD.IsMatch(patch) && !IsQuotedRegex(patch))) findings.Add($"{prefix}: '{patch}' is not a patch name."); - foreach (var field in response.Fields.Where(field => !WORD.IsMatch(field))) + foreach (var field in response.Fields.Where(field => !FIELD.IsMatch(field))) findings.Add($"{prefix}: '{field}' is not a field name."); foreach (var parameter in response.Parameters) { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamAllowListTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamAllowListTests.cs index dcc38b3..058fe99 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamAllowListTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamAllowListTests.cs @@ -105,7 +105,7 @@ public void TheFlagsTheControllerDecidesAreForbiddenTest() [Test] public void TheControllersOwnStepIsNeitherAUtilityNorASolverTest() { - Assert.That(FoamAllowList.BUILT_IN_STEPS, Is.EqualTo(new[] { FoamAllowList.RESTORE_INITIAL_FIELDS })); + Assert.That(FoamAllowList.BUILT_IN_STEPS, Is.EqualTo(new[] { FoamAllowList.RESTORE_INITIAL_FIELDS, FoamAllowList.INCLUDE_FUNCTION })); Assert.That(FoamAllowList.RESTORE_INITIAL_FIELDS, Is.EqualTo("restore0Dir"), "the name OpenFOAM's RunFunctions give it, so a recipe reads like the Allrun it came from"); Assert.That(FoamAllowList.IsBuiltIn("restore0Dir"), Is.True); Assert.That(FoamAllowList.IsBuiltIn("restore0dir"), Is.False); @@ -117,5 +117,15 @@ public void TheControllersOwnStepIsNeitherAUtilityNorASolverTest() Assert.That(FoamAllowList.UTILITIES, Has.No.Member("restore0Dir")); } + [Test] + public void TheStepThatAddsAResponseToTheSolveIsTheControllersOwnTest() + { + Assert.That(FoamAllowList.INCLUDE_FUNCTION, Is.EqualTo("includeFunc"), "named after the controlDict directive it writes, #includeFunc"); + Assert.That(FoamAllowList.IsBuiltIn("includeFunc"), Is.True); + Assert.That(FoamAllowList.IsUtility("includeFunc"), Is.False, "no kit executable carries the name"); + Assert.That(FoamAllowList.IsSolverName("includeFunc"), Is.False); + Assert.That(FoamAllowList.IsParallelCapable("includeFunc"), Is.False); + } + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamCaseRulesTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamCaseRulesTests.cs index dcdde69..d2a264e 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamCaseRulesTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamCaseRulesTests.cs @@ -79,5 +79,22 @@ public void TheKitIsAskedOnlyWhenAPredicateIsGivenTest() Assert.That(asked, Is.EquivalentTo(new[] { "simpleFoam", "blockMesh", "simpleFoam" })); } + [Test] + public void AResponseAddedToTheSolveIsOneTheTaskRequestsTest() + { + FoamCaseData Including(string response) + { + var data = PitzDaily(); + data.Extraction!.Responses.Add(new FoamResponseSpecData { Name = "load", Kind = FoamResponseKind.Forces, Patches = ["lowerWall"] }); + data.Recipe!.Steps.Insert(1, new FoamStepData { Utility = "includeFunc", Arguments = [response] }); + return data; + } + + Assert.That(FoamCaseRules.Validate(Including("load")), Is.Empty); + Assert.That(FoamCaseRules.Validate(Including("streamlines")), + Is.EqualTo(new[] { "Step 2: includeFunc streamlines names no response of this task; the step adds only the task's own responses to the solve." }), + "the node's copy of controlDict gains nothing the request did not ask for"); + } + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs index 231b02d..084419c 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs @@ -253,5 +253,44 @@ public void OnlyTheProcessorFormAfterADecompositionIsAcceptedTest() Assert.That(undecomposed, Is.EqualTo(new[] { "Step 1: restore0Dir -processor needs a decomposePar step before it." })); } + [Test] + public void AResponseAddedToTheSolveIsAcceptedWithoutAKitExecutableTest() + { + var recipe = new FoamRecipeData + { + Application = "simpleFoam", + Steps = + [ + new FoamStepData { Utility = "blockMesh" }, + new FoamStepData { Utility = "includeFunc", Arguments = ["torque"] }, + new FoamStepData { Utility = "simpleFoam" } + ] + }; + + Assert.That(FoamRecipeRules.Validate(recipe, name => name != "includeFunc"), Is.Empty, "the controller's own step needs nothing from the kit"); + } + + [Test] + public void AResponseAddedToTheSolveNamesOneResponseBeforeTheSolveTest() + { + FoamRecipeData Recipe(params FoamStepData[] steps) => new() { Application = "simpleFoam", Steps = [.. steps] }; + var solve = new FoamStepData { Utility = "simpleFoam" }; + var postSolve = new FoamStepData { Utility = "simpleFoam", Arguments = ["-postProcess", "-func", "torque", "-latestTime"] }; + + var bare = FoamRecipeRules.Validate(Recipe(new FoamStepData { Utility = "includeFunc" }, solve)); + var two = FoamRecipeRules.Validate(Recipe(new FoamStepData { Utility = "includeFunc", Arguments = ["torque", "load"] }, solve)); + var notAWord = FoamRecipeRules.Validate(Recipe(new FoamStepData { Utility = "includeFunc", Arguments = ["../controlDict"] }, solve)); + var underMpi = FoamRecipeRules.Validate(Recipe(new FoamStepData { Utility = "includeFunc", Arguments = ["torque"], Parallel = true }, solve)); + var afterTheSolve = FoamRecipeRules.Validate(Recipe(solve, new FoamStepData { Utility = "includeFunc", Arguments = ["torque"] }, postSolve)); + + var form = "Step 1 (includeFunc): the only form is 'includeFunc ', one response name."; + Assert.That(bare, Is.EqualTo(new[] { form })); + Assert.That(two, Is.EqualTo(new[] { form })); + Assert.That(notAWord, Is.EqualTo(new[] { form })); + Assert.That(underMpi, Is.EqualTo(new[] { "Step 1: 'includeFunc' is done by the controller itself, never under MPI." })); + Assert.That(afterTheSolve, Is.EqualTo(new[] { "Step 2: includeFunc torque adds a response to the solve, and no step after it runs 'simpleFoam' to solve." }), + "the solver's -postProcess form measures after the solve, which is what the step exists to avoid"); + } + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamResponseRulesTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamResponseRulesTests.cs index bb8c9d0..776e1ba 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamResponseRulesTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamResponseRulesTests.cs @@ -126,5 +126,40 @@ public void APlaceholderStandsForAValueTheSweepSuppliesTest() Assert.That(FoamResponseRules.Validate(Coefficients("{{name}}")), Has.Some.Contains("not a plain dictionary value"), "only the sweep's own token shape"); } + [Test] + public void APhaseFieldNamedWithADotIsAFieldTest() + { + static FoamExtractionRequestData Depth(string field) => new() + { + Responses = + [ + new FoamResponseSpecData + { + Name = "depth", Kind = FoamResponseKind.PatchValue, Patches = ["inlet"], Fields = [field], Operation = "areaIntegrate" + } + ] + }; + + Assert.That(FoamResponseRules.Validate(Depth("alpha.water")), Is.Empty, "every multiphase solver names its phases so"); + Assert.That(FoamResponseRules.Validate(Depth("T.solid")), Is.Empty, "and a region's fields"); + Assert.That(FoamResponseRules.Validate(Depth(".water")), Has.Some.Contains("'.water' is not a field name"), "a word starts with a letter"); + Assert.That(FoamResponseRules.Validate(Depth("U:Transformed")), Has.Some.Contains("'U:Transformed' is not a field name"), + "a colon is no file name on a Windows node, and a probe writes one file per field"); + } + + [Test] + public void APatchOrAResponseNameStaysAWordWithoutADotTest() + { + static FoamExtractionRequestData Load(string name, string patch) => new() + { + Responses = [new FoamResponseSpecData { Name = name, Kind = FoamResponseKind.Forces, Patches = [patch] }] + }; + + Assert.That(FoamResponseRules.Validate(Load("load.x", "wall")), Is.EqualTo(new[] { "Response name 'load.x' is not a word (letters, digits, underscore)." }), + "a response name is a column prefix and a file of system/"); + Assert.That(FoamResponseRules.Validate(Load("load", "wall.a")), Is.EqualTo(new[] { "Response 'load': 'wall.a' is not a patch name." }), + "the dot is a field's, not a patch's"); + } + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs index d21c5ba..08c6eb6 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamSupportedInputsDocumentTests.cs @@ -88,6 +88,8 @@ public void TheBuiltInStepsAndTheForbiddenFlagsAreTheRulesTest() var builtIn = Section("### Built-in steps").Where(line => line.StartsWith("- ")).Select(line => CodeIn(line)[0].Split(' ')[0]); Assert.That(builtIn, Is.EqualTo(FoamAllowList.BUILT_IN_STEPS)); Assert.That(m_document, Does.Contain($"`{FoamAllowList.RESTORE_INITIAL_FIELDS} {FoamAllowList.PROCESSOR_FORM}`")); + Assert.That(m_document, Does.Contain($"`{FoamAllowList.INCLUDE_FUNCTION} `")); + Assert.That(m_document, Does.Contain($"`#{FoamAllowList.INCLUDE_FUNCTION} `"), "the one line the step writes, said where the case's files are said to travel as they are"); var arguments = Section("### Arguments"); var flags = arguments[arguments.FindIndex(line => line.EndsWith("refused in a recipe:")) + 2]; diff --git a/OpenFOAM/SUPPORTED-INPUTS.md b/OpenFOAM/SUPPORTED-INPUTS.md index 7ec9a04..1d47163 100644 --- a/OpenFOAM/SUPPORTED-INPUTS.md +++ b/OpenFOAM/SUPPORTED-INPUTS.md @@ -31,7 +31,11 @@ A case is a **reconstructed** case directory: `system/controlDict` with an `0.orig/` as `0/` when it has no `0/`), and either a mesh in `constant/polyMesh` or a step that makes one. Every file travels as it is: the node substitutes a study's tokens byte for byte, adds the function objects the responses need and, -for a parallel run, writes its own `decomposeParDict`; nothing else changes. +for a parallel run, writes its own `decomposeParDict`. One more change, only +when a recipe asks for it (the `includeFunc` step below): one line, +`#includeFunc `, in the `functions` of the node's copy of +`system/controlDict`, so that the solver measures a force while it solves. The +user's own files are never changed; nothing else changes in the copy. A case file path is refused when it @@ -115,6 +119,18 @@ A parallel step naming a utility not marked here is refused. - `restore0Dir -processor` - puts the initial fields into every processor directory after `decomposePar` (what a case meshed in parallel needs). Its only form; it needs a `decomposePar` step before it and never runs under MPI. +- `includeFunc ` - adds one of the task's responses to the solve: the + line `#includeFunc ` at the end of the `functions` of the node's copy + of `system/controlDict` (a `functions` block of its own when the file has + none), and a `log.includeFunc` that says what was added, where and why. Why: + a force measured after the solve (` -postProcess`) sees the walls a + rotating zone (MRF) turns at rest, because OpenFOAM moves them only inside the + solve - the torque comes out wrong by an order of magnitude or more, even in + sign; measured by the solver, a force is the solve's own. It names one + response of the task, it needs a step after it that runs the application to + solve, and it never runs under MPI. When the `functions` entry is not a block + (`functions #includeEtc "..."`), the step fails with that line and leaves the + copy as it was, rather than rewrite it. ### Arguments @@ -140,9 +156,13 @@ object the case already runs or one the controller adds for the run: | `Probe` | `probes` | at least one field, a `probeLocations` parameter | A response's name is a word, unique in the request, and not the name of a file -the case carries in `system/`. A value is reported only from the run's final -time: a function object that stopped writing earlier is left out and named in -`log.responses`. In a parameter study a response parameter may carry the +the case carries in `system/`. A patch is a word or a quoted regular +expression; a field is a word that may carry dots, as phases are named +(`alpha.water`) - never a colon, since a probe writes one file per field. A +response the case does not write itself is measured either during the solve +(`includeFunc`, which a force on a turning wall needs) or after it by a post +step. A value is reported only from the run's final time: a function object +that stopped writing earlier is left out and named in `log.responses`. In a parameter study a response parameter may carry the study's token (a reference speed that follows the swept speed). ## Solver classes From 9e3ba912b89f8dd833f6516e6b16259dc7503f8d Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:08 +0300 Subject: [PATCH 2/4] fix(openfoam): the cell count is the mesh the run ended with A snapped case reported the background mesh blockMesh made (5,000 cells for 185,237): the first log with a count won, and snappyHexMesh words its count as "Snapped mesh : cells:N". The count now comes from the note OpenFOAM writes into every owner header (the latest time's mesh, else constant's; the processor meshes summed for a decomposed solve), else from the last count a step's log reports, snappyHexMesh's mesh lines included and its ideal layer count left out. Co-Authored-By: Claude Opus 5.5 --- .../Program.cs | 14 +- .../Extraction/FoamLogReaderTests.cs | 17 +++ .../Extraction/FoamMeshReaderTests.cs | 132 ++++++++++++++++++ .../Oracle/FoamKitMeshingOracleTests.cs | 13 ++ .../Runtime/FoamCaseSessionTests.cs | 26 ++++ .../Extraction/FoamLogReader.cs | 9 ++ .../Extraction/FoamMeshReader.cs | 112 +++++++++++++++ .../Runtime/FoamCaseSession.cs | 19 ++- 8 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamMeshReaderTests.cs create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamMeshReader.cs diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests.FakeFoam/Program.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests.FakeFoam/Program.cs index 7fbe36a..72f89ca 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests.FakeFoam/Program.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests.FakeFoam/Program.cs @@ -14,17 +14,29 @@ // the forceCoeffs function object writes; // - otherwise it "solves" ITERATIONS steps (system/fake may say // "ITERATIONS=n"), converging on the last one, and writes /U. +// Every copy prints "nCells: 12225" unless system/fake says +// "CELLS.=n" for the copy's name: n cells, or no count for 0. var caseDirectory = Directory.GetCurrentDirectory(); var control = Path.Combine(caseDirectory, "system", "fake"); var text = File.Exists(control) ? File.ReadAllText(control) : string.Empty; +var name = Path.GetFileNameWithoutExtension(Environment.ProcessPath ?? "fake-foam"); Console.WriteLine("/*---------------------------------------------------------------------------*\\"); Console.WriteLine("| fake-foam: a stand-in for an OpenFOAM solver |"); Console.WriteLine("\\*---------------------------------------------------------------------------*/"); Console.WriteLine($"Case : {caseDirectory}"); Console.WriteLine($"Args : {string.Join(' ', args)}"); -Console.WriteLine("nCells: 12225"); + +var cells = 12225L; +foreach (var line in text.Split('\n')) +{ + if (line.StartsWith($"CELLS.{name}=", StringComparison.Ordinal) && long.TryParse(line[$"CELLS.{name}=".Length..].Trim(), out var value)) + cells = value; +} + +if (cells > 0) + Console.WriteLine($"nCells: {cells}"); if (text.Contains("FAKE-ECHO", StringComparison.Ordinal)) { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamLogReaderTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamLogReaderTests.cs index b954229..db96533 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamLogReaderTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamLogReaderTests.cs @@ -63,6 +63,23 @@ public void AMissingLogYieldsEmptyFactsTest() Assert.That(facts.ReachedEnd, Is.False); } + [Test] + public void TheMeshSnappyHexMeshEndsWithIsItsCellCountTest() + { + const string log = + "Refined mesh : cells:325794 faces:1043271 points:390436 unbalance:0.00753237\n" + + "Snapped mesh : cells:325794 faces:1028155 points:380922 unbalance:0.00753237\n" + + "Before layer addition : cells:325794 faces:1025615 points:379219 unbalance:0.00753237\n" + + "Mesh with layers : cells:353854 faces:1108726 points:406385 unbalance:0.0464203\n" + + "Layer mesh : cells:353854 faces:1108726 points:406385 unbalance:0.00987413\n" + + "Ideal layer addition : cells:375243 unbalance:0.122942\n" + + "Finished meshing in = 34.2 s.\nEnd\n"; + + var facts = FoamLogReader.Read(new StringReader(log)); + + Assert.That(facts.CellCount, Is.EqualTo(353854), "the last mesh the log reports; the ideal count is a target, not a mesh"); + } + [Test] public void AFatalErrorIsFlaggedTest() { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamMeshReaderTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamMeshReaderTests.cs new file mode 100644 index 0000000..bbe05e0 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamMeshReaderTests.cs @@ -0,0 +1,132 @@ +using System.IO.Compression; +using System.Text; +using OutWit.Controller.OpenFOAM.Extraction; +using OutWit.Controller.OpenFOAM.Tests.Utils; + +namespace OutWit.Controller.OpenFOAM.Tests.Extraction; + +/// +/// The size of the mesh a run ended with, read from the note OpenFOAM writes +/// into the header of every owner it writes: the case's own mesh, or +/// the processor meshes summed when the solve ran decomposed - a case meshed +/// on its decomposed form keeps the background mesh in its root. +/// +[TestFixture] +public class FoamMeshReaderTests +{ + private string m_case = null!; + + [SetUp] + public void Setup() + { + m_case = OpenFOAMTestPaths.CreateScratch("foam-mesh"); + } + + [TearDown] + public void TearDown() + { + OpenFOAMTestPaths.TryDelete(m_case); + } + + #region Tools + + private static string Header(long cells, string format = "binary") + { + return "FoamFile\n{\n version 2.0;\n format " + format + ";\n class labelList;\n" + + $" note \"nPoints:2002 nCells:{cells} nFaces:3700 nInternalFaces:1700\";\n" + + " object owner;\n}\n// * * * //\n\n"; + } + + private void WriteOwner(string relativeDirectory, long cells) + { + var directory = Path.Combine(m_case, relativeDirectory); + Directory.CreateDirectory(directory); + + // A binary body after the header, as a binary-format mesh has it. + var bytes = Encoding.ASCII.GetBytes(Header(cells) + "3700\n(").Concat(new byte[] { 0, 0, 0, 0, 0xFF, 0xFE, 0x0A, 0x7D }).ToArray(); + File.WriteAllBytes(Path.Combine(directory, "owner"), bytes); + } + + #endregion + + #region Serial Tests + + [Test] + public void TheCasesMeshIsReadFromItsOwnerHeaderTest() + { + WriteOwner("constant/polyMesh", 185237); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(185237)); + } + + [Test] + public void AMeshWrittenAtALaterTimeIsTheOneTheRunEndedWithTest() + { + WriteOwner("constant/polyMesh", 5000); + WriteOwner("0.5/polyMesh", 7200); + WriteOwner("2/polyMesh", 9100); + Directory.CreateDirectory(Path.Combine(m_case, "10")); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(9100), "the latest time that carries a mesh"); + } + + [Test] + public void ACompressedOwnerIsReadTest() + { + var directory = Path.Combine(m_case, "constant", "polyMesh"); + Directory.CreateDirectory(directory); + using (var file = File.Create(Path.Combine(directory, "owner.gz"))) + using (var zip = new GZipStream(file, CompressionLevel.Fastest)) + zip.Write(Encoding.ASCII.GetBytes(Header(43066, "ascii") + "3700\n(\n0\n0\n)\n")); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(43066)); + } + + [Test] + public void AnOwnerWithoutTheNoteOrNoMeshIsUnknownTest() + { + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(0), "no mesh"); + + var directory = Path.Combine(m_case, "constant", "polyMesh"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "owner"), "FoamFile\n{\n format ascii;\n class labelList;\n object owner;\n}\n43066\n(\n0\n)\n"); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(0), "a mesh written without the note, as older tools shipped some"); + } + + #endregion + + #region Decomposed Tests + + [Test] + public void ADecomposedSolveCountsTheProcessorMeshesNotTheBackgroundTest() + { + WriteOwner("constant/polyMesh", 5000); + WriteOwner("processor0/constant/polyMesh", 92000); + WriteOwner("processor1/constant/polyMesh", 93237); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: true), Is.EqualTo(185237)); + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: false), Is.EqualTo(5000)); + } + + [Test] + public void AProcessorWithoutAReadableMeshMakesTheSumUnknownTest() + { + WriteOwner("constant/polyMesh", 5000); + WriteOwner("processor0/constant/polyMesh", 92000); + Directory.CreateDirectory(Path.Combine(m_case, "processor1", "constant", "polyMesh")); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: true), Is.EqualTo(0), "half a sum is not the mesh; the root's is the background's"); + } + + [Test] + public void ADecomposedSolveWithoutProcessorDirectoriesIsUnknownTest() + { + WriteOwner("constant/polyMesh", 5000); + Directory.CreateDirectory(Path.Combine(m_case, "processors2")); + + Assert.That(FoamMeshReader.CellCount(m_case, decomposed: true), Is.EqualTo(0), "the collated layout is not read"); + } + + #endregion +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitMeshingOracleTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitMeshingOracleTests.cs index cdc77f4..a8d645c 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitMeshingOracleTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitMeshingOracleTests.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text; +using OutWit.Controller.OpenFOAM.Extraction; using OutWit.Controller.OpenFOAM.Model; using OutWit.Controller.OpenFOAM.Runtime; using OutWit.Controller.OpenFOAM.Tests.Mock; @@ -89,6 +90,7 @@ public async Task MeshingOnTheDecomposedCaseRunsOnTheRestoredFieldsTest() Assert.That(ArtifactText(result, "20/U"), Does.Contain($"uniform ({VARIANT_INLET} 0 0)"), "the variant's inlet value reached the processors through the restore"); Assert.That(ArtifactEntries(result).Where(entry => entry.StartsWith("0.orig/", StringComparison.Ordinal) || entry.StartsWith("processor", StringComparison.Ordinal)), Is.Empty, "the kept initial fields and the decomposed case never travel back"); + AssertTheCellCountIsTheSnappedMesh(result); } [Test] @@ -117,6 +119,7 @@ public async Task OnANodeWithoutMpiTheSameRecipeMeshesAndSolvesTheWholeCaseTest( Assert.That(result.Steps.Single(step => step.Utility == "restore0Dir").ExitCode, Is.EqualTo(0)); Assert.That(ArtifactText(result, "constant/polyMesh/boundary"), Does.Contain("sphere")); Assert.That(ArtifactText(result, "20/U"), Does.Contain($"uniform ({VARIANT_INLET} 0 0)")); + AssertTheCellCountIsTheSnappedMesh(result); } #endregion @@ -194,6 +197,16 @@ private FoamTaskData TaskFor(int threads, bool restore) }; } + /// The run reports the mesh the solver ran on: the one snappyHexMesh wrote, not the box blockMesh made for it. + private void AssertTheCellCountIsTheSnappedMesh(FoamResultData result) + { + var background = FoamLogReader.Read(new StringReader(ArtifactText(result, "log.blockMesh"))).CellCount; + var snapped = FoamLogReader.Read(new StringReader(ArtifactText(result, "log.snappyHexMesh"))).CellCount; + + Assert.That(snapped, Is.GreaterThan(0).And.Not.EqualTo(background), "the fixture's sphere takes cells out of the box"); + Assert.That(result.CellCount, Is.EqualTo(snapped)); + } + /// A file of the result's artifact, as text. private string ArtifactText(FoamResultData result, string entryName) { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseSessionTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseSessionTests.cs index c19f1d9..b51cb7b 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseSessionTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseSessionTests.cs @@ -169,6 +169,32 @@ public async Task ACaseRunsToAResultWithFactsResponsesAndAnArtifactTest() Assert.That(entries, Does.Contain("log.blockMesh").And.Contain("log.checkMesh")); } + [Test] + public async Task TheCellCountIsTheLastOneAStepReportsTest() + { + var session = RequireSession(); + + var result = await session.RunAsync(PitzTask("ITERATIONS=1\nCELLS.blockMesh=5000\nCELLS.checkMesh=185237\nCELLS.simpleFoam=0\n")); + + Assert.That(result.ExitCode, Is.EqualTo(0), result.LogTail); + Assert.That(result.CellCount, Is.EqualTo(185237), "the background mesh blockMesh reports is not the mesh a later step made"); + } + + [Test] + public async Task TheCellCountIsTheMeshTheRunEndedWithTest() + { + var session = RequireSession(); + var data = PitzCase("ITERATIONS=1\nCELLS.blockMesh=5000\n"); + data.BaseFiles.Add(BlobFile("constant/polyMesh/owner", + "FoamFile\n{\n format ascii;\n class labelList;\n" + + " note \"nPoints:218801 nCells:185237 nFaces:589238 nInternalFaces:520000\";\n object owner;\n}\n0\n(\n)\n")); + + var result = await session.RunAsync(Task(data)); + + Assert.That(result.ExitCode, Is.EqualTo(0), result.LogTail); + Assert.That(result.CellCount, Is.EqualTo(185237), "the mesh on disk outranks what a log said on the way"); + } + [Test] public async Task TheSubstitutedFilesReachTheSolverTest() { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamLogReader.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamLogReader.cs index 0ac1e9c..71cdb4b 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamLogReader.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamLogReader.cs @@ -21,6 +21,13 @@ public static class FoamLogReader private static readonly Regex CELLS = new(@"^\s*(?:nCells|cells):\s+(\d+)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + /// + /// A mesh snappyHexMesh reports as it goes (Snapped mesh : cells:185237 + /// faces:...); the last one is the mesh it wrote. The ideal layer + /// count is a target, not a mesh. + /// + private static readonly Regex MESH_CELLS = new(@"^(?!Ideal )[A-Z][A-Za-z ]* : cells:(\d+)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + private const string END = "End"; private const string WARNING = "--> FOAM Warning"; @@ -107,6 +114,8 @@ public static FoamLogFacts Read(TextReader reader) else { var cells = CELLS.Match(line); + if (!cells.Success) + cells = MESH_CELLS.Match(line); if (cells.Success && long.TryParse(cells.Groups[1].Value, out var count)) facts.CellCount = count; } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamMeshReader.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamMeshReader.cs new file mode 100644 index 0000000..70c4d7b --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamMeshReader.cs @@ -0,0 +1,112 @@ +using System.IO.Compression; +using System.Text.RegularExpressions; +using OutWit.Controller.OpenFOAM.Model; +using OutWit.Controller.OpenFOAM.Runtime; + +namespace OutWit.Controller.OpenFOAM.Extraction; + +/// +/// The size of the mesh a run ended with, read from the note OpenFOAM writes +/// into the header of every owner it writes +/// ("nPoints:218801 nCells:185237 ...") - a header in ASCII whatever +/// the format of the body, so a binary or a compressed mesh costs a few +/// lines. The mesh is the latest time's that carries one, else +/// constant/polyMesh's; for a decomposed solve, the processor meshes +/// summed, because a case meshed on its decomposed form keeps the background +/// mesh in its root. Unknown (0) when a mesh is missing, carries no note, or +/// sits in the collated layout: the logs answer then. +/// +public static class FoamMeshReader +{ + #region Constants + + private const string CONSTANT = "constant"; + + private const string POLY_MESH = "polyMesh"; + + private static readonly string[] OWNER_FILES = ["owner", "owner.gz"]; + + /// A header is a dozen lines; a file with no header this far has none. + private const int MAX_HEADER_LINES = 40; + + private static readonly Regex NOTE_CELLS = new(@"nCells:\s*(\d+)", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex PROCESSOR_NAME = new(@"^processor\d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + #endregion + + #region Functions + + /// + /// Reads the cell count of the mesh the run ended with. + /// + /// The case. + /// True when the solve ran on the processor directories. + /// The cell count; 0 when it cannot be read. + public static long CellCount(string caseDirectory, bool decomposed) + { + if (!decomposed) + return CellsOf(caseDirectory); + + var processors = Directory.EnumerateDirectories(caseDirectory) + .Where(directory => PROCESSOR_NAME.IsMatch(Path.GetFileName(directory))) + .ToList(); + if (processors.Count == 0) + return 0; + + long total = 0; + foreach (var processor in processors) + { + var cells = CellsOf(processor); + if (cells == 0) + return 0; + + total += cells; + } + + return total; + } + + #endregion + + #region Tools + + /// The cells of the latest mesh of one case (or processor) directory; 0 when unreadable. + private static long CellsOf(string directory) + { + var candidates = FoamArtifactPacker.TimeDirectories(directory, FoamArtifactTimes.All) + .Reverse() + .Append(Path.Combine(directory, CONSTANT)); + + foreach (var candidate in candidates) + { + var owner = OWNER_FILES.Select(file => Path.Combine(candidate, POLY_MESH, file)).FirstOrDefault(File.Exists); + if (owner != null) + return ReadNote(owner); + } + + return 0; + } + + private static long ReadNote(string owner) + { + using var file = File.OpenRead(owner); + using var stream = owner.EndsWith(".gz", StringComparison.Ordinal) ? new GZipStream(file, CompressionMode.Decompress) : (Stream)file; + using var reader = new StreamReader(stream); + + for (var index = 0; index < MAX_HEADER_LINES && reader.ReadLine() is { } line; index++) + { + var note = NOTE_CELLS.Match(line); + if (note.Success) + return long.TryParse(note.Groups[1].Value, out var cells) ? cells : 0; + + // The end of the FoamFile header: what follows is the body. + if (line.TrimStart().StartsWith('}')) + return 0; + } + + return 0; + } + + #endregion +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs index f79b442..e3b2511 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs @@ -145,23 +145,30 @@ private void ReadFacts(FoamResultData result, string caseDirectory, FoamRecipeDa .ToList(); var checkMeshLog = report.LogPathOf(step => step.Utility == "checkMesh") ?? Path.Combine(caseDirectory, "log.checkMesh"); - var (verdict, checkedCells) = FoamCheckMeshReader.Read(checkMeshLog); + var (verdict, _) = FoamCheckMeshReader.Read(checkMeshLog); result.CheckMeshVerdict = verdict; // Only the logs this run wrote: the base tree may not carry logs at all - // (the materializer refuses them), so these are the steps' own. + // (the materializer refuses them), so these are the steps' own. They + // run in step order, so the last count is the latest mesh a step + // reported - snappyHexMesh's, not the background mesh blockMesh made. var warnings = 0; - long cells = checkedCells; + long logged = 0; foreach (var log in report.LogPaths) { var facts = FoamLogReader.Read(log); warnings += facts.WarningCount; - if (cells == 0 && facts.CellCount > 0) - cells = facts.CellCount; + if (facts.CellCount > 0) + logged = facts.CellCount; } result.WarningCount = warnings; - result.CellCount = cells; + + // The mesh on disk outranks the logs: it is the mesh the run ended with + // whatever step wrote it, one that prints no count included. + var decomposed = report.Steps.Any(step => step.Utility == recipe.Application && step.Ranks > 1); + var meshed = FoamMeshReader.CellCount(caseDirectory, decomposed); + result.CellCount = meshed > 0 ? meshed : logged; } private FoamResponseRowData ExtractResponses(string caseDirectory, FoamExtractionRequestData? extraction, double finalTime) From c6043ec7908bb3ad16fba9d9396a727e6fcd3142 Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:08 +0300 Subject: [PATCH 3/4] feat(openfoam): 1.1.0 - includeFunc adds a force to the solve in the node's copy The runner does includeFunc in-process, like restore0Dir: one line at the end of the last top-level functions block of the node's copy of system/controlDict (a block of its own when there is none), never inside a comment, a string, a #{ #} code block or a nested dictionary; the file's bytes and line endings kept. A functions entry that is not a block fails the step with its line and the copy unchanged. log.includeFunc says what was added, where and why. The kit oracle holds it on a Couette flow the MRF turns: the torque measured during the solve is the closed form to 0.3 %; the same response measured by simpleFoam -postProcess is +23.2 against -1.68. Co-Authored-By: Claude Opus 5.5 --- .../Fixtures/CouetteMrf/0/U | 31 ++ .../Fixtures/CouetteMrf/0/p | 24 ++ .../CouetteMrf/constant/MRFProperties | 20 ++ .../CouetteMrf/constant/transportProperties | 11 + .../CouetteMrf/constant/turbulenceProperties | 9 + .../Fixtures/CouetteMrf/system/blockMeshDict | 106 ++++++ .../Fixtures/CouetteMrf/system/controlDict | 47 +++ .../Fixtures/CouetteMrf/system/fvSchemes | 39 +++ .../Fixtures/CouetteMrf/system/fvSolution | 48 +++ .../FoamKitSolveFunctionsOracleTests.cs | 224 +++++++++++++ .../Runtime/FoamCaseRunnerTests.cs | 66 ++++ .../Runtime/FoamSolveFunctionsTests.cs | 197 ++++++++++++ .../Extraction/FoamFunctionObjectWriter.cs | 10 +- .../OutWit.Controller.OpenFOAM.csproj | 2 +- OpenFOAM/OutWit.Controller.OpenFOAM/README.md | 31 +- .../Runtime/FoamCaseRunner.cs | 18 +- .../Runtime/FoamSolveFunctions.cs | 301 ++++++++++++++++++ 17 files changed, 1170 insertions(+), 14 deletions(-) create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/U create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/p create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/MRFProperties create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/transportProperties create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/turbulenceProperties create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/blockMeshDict create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/controlDict create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSchemes create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSolution create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitSolveFunctionsOracleTests.cs create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamSolveFunctionsTests.cs create mode 100644 OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamSolveFunctions.cs diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/U b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/U new file mode 100644 index 0000000..7de8d33 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/U @@ -0,0 +1,31 @@ +FoamFile +{ + version 2.0; + format ascii; + class volVectorField; + object U; +} + +dimensions [0 1 -1 0 0 0 0]; + +internalField uniform (0 0 0); + +boundaryField +{ + // At rest as written; the MRF moves the inner wall while it solves, and + // writes it back at rest - the case I-37 was found on. + innerWall + { + type noSlip; + } + + outerWall + { + type noSlip; + } + + frontAndBack + { + type empty; + } +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/p b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/p new file mode 100644 index 0000000..7243907 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/p @@ -0,0 +1,24 @@ +FoamFile +{ + version 2.0; + format ascii; + class volScalarField; + object p; +} + +dimensions [0 2 -2 0 0 0 0]; + +internalField uniform 0; + +boundaryField +{ + "innerWall|outerWall" + { + type zeroGradient; + } + + frontAndBack + { + type empty; + } +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/MRFProperties b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/MRFProperties new file mode 100644 index 0000000..fc60dbe --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/MRFProperties @@ -0,0 +1,20 @@ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object MRFProperties; +} + +// Every cell turns; the inner wall turns with them, the outer one does not. +rotor +{ + cellZone rotor; + active yes; + + nonRotatingPatches (outerWall); + + origin (0 0 0); + axis (0 0 1); + omega 1; +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/transportProperties b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/transportProperties new file mode 100644 index 0000000..65aea3c --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/transportProperties @@ -0,0 +1,11 @@ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object transportProperties; +} + +transportModel Newtonian; + +nu 1; diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/turbulenceProperties b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/turbulenceProperties new file mode 100644 index 0000000..b75c495 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/constant/turbulenceProperties @@ -0,0 +1,9 @@ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object turbulenceProperties; +} + +simulationType laminar; diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/blockMeshDict b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/blockMeshDict new file mode 100644 index 0000000..0015faa --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/blockMeshDict @@ -0,0 +1,106 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + An annulus between r = 1 and r = 2, one cell deep (z = 0 .. 0.1): four + blocks of 20 x 20 cells, every cell in the zone the MRF turns. +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object blockMeshDict; +} + +scale 1; + +vertices +( + ( 1 0 0) + ( 0 1 0) + (-1 0 0) + ( 0 -1 0) + ( 2 0 0) + ( 0 2 0) + (-2 0 0) + ( 0 -2 0) + + ( 1 0 0.1) + ( 0 1 0.1) + (-1 0 0.1) + ( 0 -1 0.1) + ( 2 0 0.1) + ( 0 2 0.1) + (-2 0 0.1) + ( 0 -2 0.1) +); + +blocks +( + hex (1 0 4 5 9 8 12 13) rotor (20 20 1) simpleGrading (1 1 1) + hex (2 1 5 6 10 9 13 14) rotor (20 20 1) simpleGrading (1 1 1) + hex (3 2 6 7 11 10 14 15) rotor (20 20 1) simpleGrading (1 1 1) + hex (0 3 7 4 8 11 15 12) rotor (20 20 1) simpleGrading (1 1 1) +); + +edges +( + arc 0 1 ( 0.70710678 0.70710678 0) + arc 1 2 (-0.70710678 0.70710678 0) + arc 2 3 (-0.70710678 -0.70710678 0) + arc 3 0 ( 0.70710678 -0.70710678 0) + arc 4 5 ( 1.41421356 1.41421356 0) + arc 5 6 (-1.41421356 1.41421356 0) + arc 6 7 (-1.41421356 -1.41421356 0) + arc 7 4 ( 1.41421356 -1.41421356 0) + + arc 8 9 ( 0.70710678 0.70710678 0.1) + arc 9 10 (-0.70710678 0.70710678 0.1) + arc 10 11 (-0.70710678 -0.70710678 0.1) + arc 11 8 ( 0.70710678 -0.70710678 0.1) + arc 12 13 ( 1.41421356 1.41421356 0.1) + arc 13 14 (-1.41421356 1.41421356 0.1) + arc 14 15 (-1.41421356 -1.41421356 0.1) + arc 15 12 ( 1.41421356 -1.41421356 0.1) +); + +boundary +( + innerWall + { + type wall; + faces + ( + (0 1 9 8) + (1 2 10 9) + (2 3 11 10) + (3 0 8 11) + ); + } + + outerWall + { + type wall; + faces + ( + (4 5 13 12) + (5 6 14 13) + (6 7 15 14) + (7 4 12 15) + ); + } + + frontAndBack + { + type empty; + faces + ( + (1 0 4 5) + (2 1 5 6) + (3 2 6 7) + (0 3 7 4) + ( 9 8 12 13) + (10 9 13 14) + (11 10 14 15) + ( 8 11 15 12) + ); + } +); diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/controlDict b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/controlDict new file mode 100644 index 0000000..5ebbeb8 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/controlDict @@ -0,0 +1,47 @@ +/*--------------------------------*- C++ -*----------------------------------*\ + Couette flow between two cylinders: the inner one turns with the zone the + MRF rotates, the outer one stands still. The controller's oracle case for a + force measured during the solve: the torque on the inner wall has a closed + form, 4 pi mu omega r1^2 r2^2 / (r2^2 - r1^2) per unit depth. +\*---------------------------------------------------------------------------*/ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object controlDict; +} + +application simpleFoam; + +startFrom startTime; + +startTime 0; + +stopAt endTime; + +endTime 400; + +deltaT 1; + +writeControl timeStep; + +writeInterval 400; + +purgeWrite 0; + +writeFormat ascii; + +writePrecision 8; + +writeCompression off; + +timeFormat general; + +timePrecision 6; + +runTimeModifiable false; + +functions +{ +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSchemes b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSchemes new file mode 100644 index 0000000..ae0bcb5 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSchemes @@ -0,0 +1,39 @@ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object fvSchemes; +} + +ddtSchemes +{ + default steadyState; +} + +gradSchemes +{ + default Gauss linear; +} + +divSchemes +{ + default none; + div(phi,U) bounded Gauss linear; + div((nuEff*dev2(T(grad(U))))) Gauss linear; +} + +laplacianSchemes +{ + default Gauss linear corrected; +} + +interpolationSchemes +{ + default linear; +} + +snGradSchemes +{ + default corrected; +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSolution b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSolution new file mode 100644 index 0000000..dfe3c18 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/system/fvSolution @@ -0,0 +1,48 @@ +FoamFile +{ + version 2.0; + format ascii; + class dictionary; + object fvSolution; +} + +solvers +{ + p + { + solver GAMG; + smoother GaussSeidel; + tolerance 1e-9; + relTol 0.05; + } + + U + { + solver smoothSolver; + smoother symGaussSeidel; + tolerance 1e-9; + relTol 0.05; + } +} + +SIMPLE +{ + nNonOrthogonalCorrectors 0; + consistent yes; + pRefCell 0; + pRefValue 0; + + residualControl + { + p 1e-6; + U 1e-7; + } +} + +relaxationFactors +{ + equations + { + U 0.9; + } +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitSolveFunctionsOracleTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitSolveFunctionsOracleTests.cs new file mode 100644 index 0000000..54a17c3 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Oracle/FoamKitSolveFunctionsOracleTests.cs @@ -0,0 +1,224 @@ +using System.IO.Compression; +using OutWit.Controller.OpenFOAM.Model; +using OutWit.Controller.OpenFOAM.Runtime; +using OutWit.Controller.OpenFOAM.Tests.Mock; +using OutWit.Controller.OpenFOAM.Tests.Utils; +using OutWit.Engine.Interfaces; + +namespace OutWit.Controller.OpenFOAM.Tests.Oracle; + +/// +/// A force measured during the solve against the REAL kit: Couette flow +/// between two cylinders, the inner one turned by a rotating zone (MRF), the +/// torque on it known in closed form. The controller's includeFunc +/// adds the response to the node's copy of controlDict and the solver +/// measures the torque the flow puts on the turning wall; the same response +/// measured after the solve (simpleFoam -postProcess) sees that wall +/// at rest, as OpenFOAM moves it only inside the solve, and misses the +/// torque by an order of magnitude and its sign - the finding the step was +/// made for. The case is the test's own fixture, small enough for every CI +/// kit. +/// +[TestFixture] +[Category("Kit")] +public class FoamKitSolveFunctionsOracleTests +{ + #region Constants + + private const string KIT_VARIABLE = FoamKitResolver.ENV_KIT_PATH; + + private const string FIXTURE = "CouetteMrf"; + + private const double INNER_RADIUS = 1; + + private const double OUTER_RADIUS = 2; + + private const double OMEGA = 1; + + private const double NU = 1; + + private const double DEPTH = 0.1; + + /// The fixture's mesh: four blocks of 20 x 20 cells. + private const long CELLS = 1600; + + /// + /// The torque on the inner cylinder, about +z: the flow holds back the + /// wall that turns it, 4 pi mu omega r1^2 r2^2 / (r2^2 - r1^2) per unit + /// depth (rho = 1). + /// + private static readonly double TORQUE = -4 * Math.PI * NU * OMEGA * INNER_RADIUS * INNER_RADIUS * OUTER_RADIUS * OUTER_RADIUS + / (OUTER_RADIUS * OUTER_RADIUS - INNER_RADIUS * INNER_RADIUS) * DEPTH; + + #endregion + + #region Fields + + private string m_storage = null!; + + private FoamTestBlobService m_blobs = null!; + + private FoamKit m_kit = null!; + + #endregion + + [SetUp] + public void Setup() + { + var kitPath = Environment.GetEnvironmentVariable(KIT_VARIABLE); + if (string.IsNullOrEmpty(kitPath)) + Assert.Ignore($"{KIT_VARIABLE} is not set; the kit oracle runs only where a kit is unpacked"); + + m_kit = FoamKitResolver.Resolve(typeof(FoamKit).Assembly.Location) + ?? throw new InvalidOperationException($"{KIT_VARIABLE}={kitPath} does not resolve to a kit"); + m_storage = OpenFOAMTestPaths.CreateScratch("foam-oracle-solve-functions"); + m_blobs = new FoamTestBlobService(m_storage); + } + + [TearDown] + public void TearDown() + { + if (m_storage != null) + OpenFOAMTestPaths.TryDelete(m_storage); + } + + #region Oracle Tests + + [Test] + public async Task ATorqueMeasuredDuringTheSolveIsTheFlowsTest() + { + var result = await Run(threads: 1); + + Assert.That(result.Rejections, Is.Empty); + Assert.That(result.ExitCode, Is.EqualTo(0), result.LogTail); + Assert.That(result.Steps.Select(step => (step.Utility, step.Ranks)), Is.EqualTo(new[] + { + ("blockMesh", 1), ("includeFunc", 0), ("simpleFoam", 1), ("simpleFoam", 1) + })); + Assert.That(result.Converged, Is.True); + Assert.That(Value(result, "torque.moment.total_z"), Is.EqualTo(TORQUE).Within(1).Percent, "the closed form, to the mesh's error"); + Assert.That(Math.Abs(Value(result, "torqueAfter.moment.total_z") - TORQUE), Is.GreaterThan(Math.Abs(TORQUE)), + "measured after the solve, the turning wall is at rest and the torque is not the flow's"); + Assert.That(result.CellCount, Is.EqualTo(CELLS)); + + Assert.That(ArtifactText(result, "system/controlDict"), Does.Contain(" #includeFunc torque\n}"), "the node's copy, as the solver read it"); + Assert.That(ArtifactText(result, "log.includeFunc"), Does.Contain("rotating zone (MRF)"), "the log says what was added and why"); + } + + [Test] + public async Task ATorqueMeasuredDuringADecomposedSolveIsTheFlowsTest() + { + if (!m_kit.SupportsParallel) + Assert.Ignore("the kit has no MPI launcher on this platform"); + + var result = await Run(threads: 2); + + Assert.That(result.Rejections, Is.Empty); + Assert.That(result.ExitCode, Is.EqualTo(0), result.LogTail); + Assert.That(result.Steps.Select(step => (step.Utility, step.Ranks)), Is.EqualTo(new[] + { + ("blockMesh", 1), ("decomposePar", 1), ("includeFunc", 0), ("simpleFoam", 2), ("reconstructPar", 1) + })); + Assert.That(Value(result, "torque.moment.total_z"), Is.EqualTo(TORQUE).Within(1).Percent); + Assert.That(result.CellCount, Is.EqualTo(CELLS), "the processor meshes, summed"); + } + + #endregion + + #region Tools + + private async Task Run(int threads) + { + var session = new FoamCaseSession(m_kit, m_blobs, new WitTempStorageDefault(m_storage)); + var result = await session.RunAsync(TaskFor(threads)); + + TestContext.Out.WriteLine(result); + TestContext.Out.WriteLine("steps: " + string.Join(", ", result.Steps)); + TestContext.Out.WriteLine("responses: " + string.Join(", ", result.ResponseRow?.Values.Select(entry => $"{entry.Name}={entry.Value}") ?? [])); + TestContext.Out.WriteLine($"expected: torque.moment.total_z={TORQUE}"); + if (result.LogTail != null) + TestContext.Out.WriteLine(result.LogTail); + + return result; + } + + private FoamTaskData TaskFor(int threads) + { + var fixture = Path.Combine(TestContext.CurrentContext.TestDirectory, "Fixtures", FIXTURE); + var files = Directory.EnumerateFiles(fixture, "*", SearchOption.AllDirectories) + .Select(file => + { + var bytes = File.ReadAllBytes(file); + return new FoamFileRefData + { + RelativePath = Path.GetRelativePath(fixture, file).Replace('\\', '/'), + BlobId = m_blobs.AddBytes(bytes), + Sha256 = "n/a", + Size = bytes.Length + }; + }) + .ToList(); + + var steps = new List { new() { Utility = "blockMesh" } }; + if (threads > 1) + steps.Add(new FoamStepData { Utility = "decomposePar" }); + steps.Add(new FoamStepData { Utility = "includeFunc", Arguments = ["torque"] }); + steps.Add(new FoamStepData { Utility = "simpleFoam", Parallel = threads > 1 }); + if (threads > 1) + steps.Add(new FoamStepData { Utility = "reconstructPar", Arguments = ["-latestTime"] }); + else + steps.Add(new FoamStepData { Utility = "simpleFoam", Arguments = ["-postProcess", "-func", "torqueAfter", "-latestTime"] }); + + return new FoamTaskData + { + VariantIndex = 1, + Case = new FoamCaseData + { + BaseFiles = files, + Recipe = new FoamRecipeData { Application = "simpleFoam", Steps = steps }, + Threads = threads, + Extraction = new FoamExtractionRequestData { Responses = threads > 1 ? [Torque("torque")] : [Torque("torque"), Torque("torqueAfter")] }, + ArtifactPolicy = new FoamArtifactPolicyData { Logs = true }, + CellCount = CELLS, + SolverClass = "incompressible-steady" + } + }; + } + + private static FoamResponseSpecData Torque(string name) + { + return new FoamResponseSpecData + { + Name = name, + Kind = FoamResponseKind.Forces, + Patches = ["innerWall"], + Parameters = + [ + new FoamNamedValueData { Name = "rho", Value = "rhoInf" }, + new FoamNamedValueData { Name = "rhoInf", Value = "1" }, + new FoamNamedValueData { Name = "CofR", Value = "(0 0 0)" } + ] + }; + } + + private static double Value(FoamResultData result, string name) + { + var value = result.ResponseRow?.Values.FirstOrDefault(entry => entry.Name == name); + Assert.That(value, Is.Not.Null, $"the row carries {name}"); + return value!.Value; + } + + /// A file of the result's artifact, as text with LF line endings. + private string ArtifactText(FoamResultData result, string entryName) + { + Assert.That(result.ArtifactBlobId, Is.Not.Null); + using var archive = ZipFile.OpenRead(m_blobs.GetStoredPath(result.ArtifactBlobId!.Value)); + var entry = archive.GetEntry(entryName); + Assert.That(entry, Is.Not.Null, $"the artifact carries {entryName}"); + + using var reader = new StreamReader(entry!.Open()); + return reader.ReadToEnd().Replace("\r\n", "\n"); + } + + #endregion +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseRunnerTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseRunnerTests.cs index 7eb2917..6d53b99 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseRunnerTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamCaseRunnerTests.cs @@ -381,4 +381,70 @@ public async Task OnANodeWithoutMpiTheRestoreHasNothingToDoAndSaysSoTest() } #endregion + + #region Include Tests + + private static FoamRecipeData IncludingRecipe(bool parallel) + { + return new FoamRecipeData + { + Application = "simpleFoam", + Steps = + [ + new FoamStepData { Utility = "blockMesh" }, + new FoamStepData { Utility = "includeFunc", Arguments = ["torque"] }, + new FoamStepData { Utility = "simpleFoam", Parallel = parallel } + ] + }; + } + + [Test] + public async Task TheControllerAddsTheResponseToTheSolveBeforeTheSolverRunsTest() + { + var kit = RequireKit(); + WriteCaseFile("system/fake", "ITERATIONS=2\n"); + WriteCaseFile("system/controlDict", "application simpleFoam;\nfunctions\n{\n}\n"); + var runner = new FoamCaseRunner(kit, m_case, kit.EnvironmentFor(m_scratch), ranks: 1); + + var report = await runner.RunAsync(IncludingRecipe(parallel: false)); + + Assert.That(report.Succeeded, Is.True, report.LogTail); + Assert.That(report.Steps.Select(step => (step.Utility, step.Ranks, step.ExitCode)), Is.EqualTo(new[] { ("blockMesh", 1, 0), ("includeFunc", 0, 0), ("simpleFoam", 1, 0) })); + Assert.That(ReadCaseFile("system/controlDict"), Does.Contain(" #includeFunc torque\n}")); + Assert.That(report.LogPaths, Has.Some.EndsWith("log.includeFunc")); + } + + [Test] + public async Task AnIncludeThatCannotBeWrittenEndsTheRunBeforeTheSolveTest() + { + var kit = RequireKit(); + WriteCaseFile("system/fake", "ITERATIONS=2\n"); + WriteCaseFile("system/controlDict", "application simpleFoam;\nfunctions #includeEtc \"caseDicts/functions\";\n"); + var runner = new FoamCaseRunner(kit, m_case, kit.EnvironmentFor(m_scratch), ranks: 1); + + var report = await runner.RunAsync(IncludingRecipe(parallel: false)); + + Assert.That(report.Succeeded, Is.False); + Assert.That(report.FailedStep, Is.EqualTo("includeFunc")); + Assert.That(report.LogTail, Does.Contain("'functions' is not a block")); + Assert.That(report.Steps.Select(step => step.Utility), Is.EqualTo(new[] { "blockMesh", "includeFunc" }), "a solve that would not measure what was asked never starts"); + } + + [Test] + public async Task AnIncludeInAParallelRunLeavesTheInitialFieldsAloneTest() + { + var kit = RequireFakeKit().ResolveWithLauncher(); + Assume.That(kit.SupportsParallel, Is.True); + WriteCaseFile("system/fake", "ITERATIONS=2\n"); + WriteCaseFile("system/controlDict", "application simpleFoam;\n"); + WriteCaseFile("0/U", INITIAL_U); + var runner = new FoamCaseRunner(kit, m_case, kit.EnvironmentFor(m_scratch), ranks: 2); + + var report = await runner.RunAsync(IncludingRecipe(parallel: true)); + + Assert.That(report.Succeeded, Is.True, report.LogTail); + Assert.That(Directory.Exists(Path.Combine(m_case, "0.orig")), Is.False, "only the restore keeps the initial fields aside"); + } + + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamSolveFunctionsTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamSolveFunctionsTests.cs new file mode 100644 index 0000000..694ba07 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Runtime/FoamSolveFunctionsTests.cs @@ -0,0 +1,197 @@ +using OutWit.Controller.OpenFOAM.Runtime; +using OutWit.Controller.OpenFOAM.Tests.Utils; + +namespace OutWit.Controller.OpenFOAM.Tests.Runtime; + +/// +/// The controller's include: one line, #includeFunc <response>, at +/// the end of the top-level functions of the node's copy of +/// system/controlDict - never in a comment, a string or a nested +/// dictionary that happens to say functions - and a log that says what +/// was added and why. +/// +[TestFixture] +public class FoamSolveFunctionsTests +{ + private string m_case = null!; + + [SetUp] + public void Setup() + { + m_case = OpenFOAMTestPaths.CreateScratch("foam-solve-functions"); + Directory.CreateDirectory(Path.Combine(m_case, "system")); + } + + [TearDown] + public void TearDown() + { + OpenFOAMTestPaths.TryDelete(m_case); + } + + #region Text Tests + + [Test] + public void TheLineEndsTheCasesOwnFunctionsTest() + { + const string text = + "application simpleFoam;\n" + + "endTime 100;\n" + + "\n" + + "functions\n" + + "{\n" + + " // the case's own\n" + + " forces1 { type forces; libs (forces); patches (innerWall); }\n" + + "}\n" + + "\n" + + "// ************************************************************************* //\n"; + + var (edited, line, refusal) = FoamSolveFunctions.AddInclude(text, "torque_sweep"); + + Assert.That(refusal, Is.Null); + Assert.That(edited, Is.EqualTo( + "application simpleFoam;\n" + + "endTime 100;\n" + + "\n" + + "functions\n" + + "{\n" + + " // the case's own\n" + + " forces1 { type forces; libs (forces); patches (innerWall); }\n" + + " #includeFunc torque_sweep\n" + + "}\n" + + "\n" + + "// ************************************************************************* //\n")); + Assert.That(line, Is.EqualTo(8)); + } + + [Test] + public void ACaseWithoutFunctionsGetsABlockOfItsOwnTest() + { + const string text = "application simpleFoam;\nendTime 100;\n"; + + var (edited, line, refusal) = FoamSolveFunctions.AddInclude(text, "load"); + + Assert.That(refusal, Is.Null); + Assert.That(edited, Is.EqualTo("application simpleFoam;\nendTime 100;\n\nfunctions\n{\n #includeFunc load\n}\n")); + Assert.That(line, Is.EqualTo(6)); + } + + [Test] + public void OnlyTheTopLevelKeywordIsTheFunctionsTest() + { + const string text = + "// functions { a comment }\n" + + "/* functions\n{ } */\n" + + "note \"functions { in a string }\";\n" + + "OptimisationSwitches { functions 1; }\n" + + "myfunctions { }\n" + + "application simpleFoam;\n"; + + var (edited, _, refusal) = FoamSolveFunctions.AddInclude(text, "load"); + + Assert.That(refusal, Is.Null); + Assert.That(edited, Is.EqualTo(text + "\nfunctions\n{\n #includeFunc load\n}\n"), "no line lands in a comment, a string or a nested dictionary"); + } + + [Test] + public void BracesInACodedFunctionAreCodeNotTheBlockTest() + { + const string text = + "functions\n" + + "{\n" + + " coded { type coded; codeExecute #{ if (on) { Info<< \"}\" << endl; } #}; }\n" + + "}\n" + + "application simpleFoam;\n"; + + var (edited, line, refusal) = FoamSolveFunctions.AddInclude(text, "load"); + + Assert.That(refusal, Is.Null); + Assert.That(edited, Is.EqualTo(text.Replace("#}; }\n}\n", "#}; }\n #includeFunc load\n}\n"))); + Assert.That(line, Is.EqualTo(4)); + } + + [Test] + public void AnEmptyBlockOnOneLineOpensTest() + { + var (edited, line, _) = FoamSolveFunctions.AddInclude("application simpleFoam;\nfunctions {}\n", "load"); + + Assert.That(edited, Is.EqualTo("application simpleFoam;\nfunctions {\n #includeFunc load\n}\n")); + Assert.That(line, Is.EqualTo(3)); + } + + [Test] + public void TheLastOfTwoBlocksIsTheOneOpenFoamReadsTest() + { + const string text = "functions { a { type probes; } }\napplication simpleFoam;\nfunctions\n{\n}\n"; + + var (edited, line, _) = FoamSolveFunctions.AddInclude(text, "load"); + + Assert.That(edited, Is.EqualTo("functions { a { type probes; } }\napplication simpleFoam;\nfunctions\n{\n #includeFunc load\n}\n")); + Assert.That(line, Is.EqualTo(5)); + } + + [Test] + public void TheFilesLineEndingsAreKeptTest() + { + var (edited, _, _) = FoamSolveFunctions.AddInclude("application simpleFoam;\r\nfunctions\r\n{\r\n}\r\n", "load"); + + Assert.That(edited, Is.EqualTo("application simpleFoam;\r\nfunctions\r\n{\r\n #includeFunc load\r\n}\r\n")); + } + + [Test] + public void FunctionsThatAreNotABlockAreRefusedNotRewrittenTest() + { + var (edited, _, refusal) = FoamSolveFunctions.AddInclude("application simpleFoam;\nfunctions #includeEtc \"caseDicts/functions\";\n", "load"); + + Assert.That(edited, Is.Null); + Assert.That(refusal, Is.EqualTo( + "system/controlDict:2: 'functions' is not a block the controller can add a line to; " + + "write it as functions { ... } for a force to be measured during the solve.")); + } + + #endregion + + #region File Tests + + [Test] + public void TheNodesCopyGainsTheLineAndTheLogSaysWhatAndWhyTest() + { + var controlDict = Path.Combine(m_case, "system", "controlDict"); + File.WriteAllText(controlDict, "application simpleFoam;\nfunctions\n{\n}\n"); + var log = Path.Combine(m_case, "log.includeFunc"); + + var outcome = FoamSolveFunctions.Include(m_case, "torque_sweep", log); + + Assert.That(outcome.ExitCode, Is.EqualTo(0), outcome.LogTail); + Assert.That(File.ReadAllText(controlDict), Is.EqualTo("application simpleFoam;\nfunctions\n{\n #includeFunc torque_sweep\n}\n")); + var text = File.ReadAllText(log); + Assert.That(text, Does.Contain("#includeFunc torque_sweep").And.Contain("system/controlDict:4")); + Assert.That(text, Does.Contain("node's copy").And.Contain("the case's own files are not changed")); + Assert.That(text, Does.Contain("rotating zone (MRF)").And.Contain("-postProcess"), "the reason, in the log that travels with the variant"); + } + + [Test] + public void ARefusedIncludeLeavesTheCopyAsItWasAndFailsTheStepTest() + { + var controlDict = Path.Combine(m_case, "system", "controlDict"); + const string original = "application simpleFoam;\nfunctions #includeEtc \"caseDicts/functions\";\n"; + File.WriteAllText(controlDict, original); + var log = Path.Combine(m_case, "log.includeFunc"); + + var outcome = FoamSolveFunctions.Include(m_case, "load", log); + + Assert.That(outcome.ExitCode, Is.Not.EqualTo(0)); + Assert.That(File.ReadAllText(controlDict), Is.EqualTo(original)); + Assert.That(outcome.LogTail, Does.Contain("'functions' is not a block")); + } + + [Test] + public void ACaseWithoutAControlDictFailsTheStepByNameTest() + { + var outcome = FoamSolveFunctions.Include(m_case, "load", Path.Combine(m_case, "log.includeFunc")); + + Assert.That(outcome.ExitCode, Is.Not.EqualTo(0)); + Assert.That(outcome.LogTail, Does.Contain("system/controlDict")); + } + + #endregion +} diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamFunctionObjectWriter.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamFunctionObjectWriter.cs index 252d9b7..6dd999c 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamFunctionObjectWriter.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamFunctionObjectWriter.cs @@ -9,10 +9,12 @@ namespace OutWit.Controller.OpenFOAM.Extraction; /// dictionaries, one file per response at system/<Name> - where /// OpenFOAM's postProcess -func <Name> (and a solver's /// -postProcess form, for the quantities that need the turbulence -/// model) looks first, before its own etc/caseDicts. The user's files -/// are never edited: a response is a file of its own beside them, and the -/// recipe's post step names it. What a request may say is -/// ; this class only renders it. +/// model) looks first, before its own etc/caseDicts, and so does a +/// #includeFunc. A response is a file of its own beside the user's +/// files: the recipe's post step names it, or the controller's +/// includeFunc step adds it to the solve (). +/// What a request may say is ; this class only +/// renders it. /// public static class FoamFunctionObjectWriter { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj b/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj index e58217f..716e1e1 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj @@ -4,7 +4,7 @@ OpenFOAM - 1.0.6 + 1.1.0 Runs complete OpenFOAM cases on compute nodes: each run is one whole case on one node - meshing, decomposition, the solver, the post step - through an allow-listed recipe, the unit of parameter-study throughput. Carries pinned OpenFOAM v2606 kits for every supported node platform as controller assets, so nodes need no preinstalled OpenFOAM (parallel steps on Windows additionally need Microsoft MPI on the node); responses are read from the finished case right on the node. OpenFOAM is a registered trademark of OpenCFD Limited; this offering is not approved or endorsed by OpenCFD Limited. openfoam;cfd;solver;simulation;witengine;witcloud;controller foam-run diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md index 945e3d5..9b3f91b 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md @@ -55,9 +55,12 @@ not at the run's final time - a function object that stopped writing before the run ended - is not reported: the node names it in `log.responses`, which travels with the step logs. -## The controller's own step +## The controller's own steps + +Two steps of a recipe are the controller's rather than the kit's. Neither +runs under MPI; each writes its log like any step's (`log.restore0Dir`, +`log.includeFunc`) and reports 0 ranks, as a step no process ran. -One step of a recipe is the controller's rather than the kit's: `restore0Dir -processor`, named after OpenFOAM's `RunFunctions`. A case meshed on its decomposed form - `decomposePar` over the background mesh, then `snappyHexMesh` in parallel, as the motorBike tutorial does - needs its @@ -70,8 +73,28 @@ never runs under MPI; on a node without MPI the run is serial, there are no processor directories, and the step logs that it has nothing to do. A decomposed run without `processor` directories (a case whose `controlDict` sets a collated file handler) or without initial fields fails at this step, -by name. Its log is `log.restore0Dir`, like any step's; its outcome reports -0 ranks, as a step no process ran. +by name. + +`includeFunc ` measures a response during the solve instead of +after it. It is the one change the controller makes to a case's own files, +made only when a recipe asks for it and only in the node's copy: one line, +`#includeFunc `, at the end of the top-level `functions` block of +`system/controlDict` (a block of its own when the case has none), so the +solver runs the response's function object, `system/`, while it +solves. The reason is a force on a wall a rotating zone (MRF) turns: +OpenFOAM moves such a wall only inside the solve, so the same force measured +afterwards (` -postProcess`) sees it at rest - the torque on the +inner cylinder of a Couette flow comes out about fourteen times too large and +of the wrong sign (the kit oracle test holds both numbers). The step needs a +solve after it, names only a response of the task, and fails by line - the +copy left as it was - when `functions` is not a block the controller can add +a line to (`#includeEtc`, a `$` reference). Its log says what was added, +where, and why. + +The mesh a run reports (`CellCount`) is the one it ended with: read from the +header OpenFOAM writes on every mesh (the processor meshes summed for a +decomposed solve), else the last count a step's log reports - the mesh +`snappyHexMesh` wrote, not the background mesh `blockMesh` made for it. ## Bundled kit diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseRunner.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseRunner.cs index 4146059..3d2cfae 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseRunner.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseRunner.cs @@ -10,9 +10,10 @@ namespace OutWit.Controller.OpenFOAM.Runtime; /// ending the run. Parallel steps go through the kit's MPI launcher with /// -parallel appended; on a node without a launcher they run /// serially and the decomposition steps are skipped, so the case still -/// produces a result there. The controller's own step -/// (restore0Dir -processor) runs in-process through -/// , with a log like any step's. +/// produces a result there. The controller's own steps run in-process, with a +/// log like any step's: restore0Dir -processor through +/// , includeFunc <response> +/// through . /// public sealed class FoamCaseRunner { @@ -63,7 +64,7 @@ public async Task RunAsync(FoamRecipeData recipe, CancellationTok Logger?.LogInformation("Foam.Run: no MPI launcher on this node ({Platform}) - the parallel steps run serially.", Kit.Platform); // A restore into the processor directories restores the fields as they were before any step ran. - if (parallel && recipe.Steps.Any(step => FoamAllowList.IsBuiltIn(step.Utility))) + if (parallel && recipe.Steps.Any(step => step.Utility == FoamAllowList.RESTORE_INITIAL_FIELDS)) FoamInitialFields.Keep(CaseDirectory); var report = new FoamRunReport(); @@ -84,7 +85,7 @@ public async Task RunAsync(FoamRecipeData recipe, CancellationTok var logPath = Path.Combine(CaseDirectory, LogName(step.Utility, logCounts)); var outcome = builtIn - ? FoamInitialFields.RestoreIntoProcessors(CaseDirectory, logPath, decomposed: parallel) + ? RunBuiltIn(step, parallel, logPath) : await RunProcessAsync(step, runParallel, logPath, cancellationToken); report.Add(step, new FoamStepOutcomeData @@ -107,6 +108,13 @@ public async Task RunAsync(FoamRecipeData recipe, CancellationTok return report; } + private FoamRunOutcome RunBuiltIn(FoamStepData step, bool decomposed, string logPath) + { + return step.Utility == FoamAllowList.INCLUDE_FUNCTION + ? FoamSolveFunctions.Include(CaseDirectory, step.Arguments[0], logPath) + : FoamInitialFields.RestoreIntoProcessors(CaseDirectory, logPath, decomposed); + } + private Task RunProcessAsync(FoamStepData step, bool parallel, string logPath, CancellationToken cancellationToken) { var (fileName, arguments) = CommandLine(step, parallel); diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamSolveFunctions.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamSolveFunctions.cs new file mode 100644 index 0000000..88d2729 --- /dev/null +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamSolveFunctions.cs @@ -0,0 +1,301 @@ +using System.Diagnostics; +using System.Text; +using OutWit.Controller.OpenFOAM.Model.Rules; + +namespace OutWit.Controller.OpenFOAM.Runtime; + +/// +/// The controller's own includeFunc <response>: one line, +/// #includeFunc <response>, at the end of the top-level +/// functions block of the node's copy of system/controlDict, so +/// the solver measures the response while it solves - the response's +/// dictionary is system/<response>, which the controller wrote +/// before the first step, and #includeFunc reads the case's +/// system/ first. A case without functions gets a block of its +/// own; one whose functions is not a block (#includeEtc, a +/// $ reference) is refused by line, never rewritten. Only the last +/// top-level functions counts, as in OpenFOAM, and nothing in a +/// comment, a string, a #{ #} code block or a nested dictionary is +/// taken for it. The file's bytes are kept as they are, line endings +/// included; the case's own files, the ones the task carries, never change. +/// +public static class FoamSolveFunctions +{ + #region Constants + + private const string FUNCTIONS = "functions"; + + private const string CONTROL_DICT = "system/controlDict"; + + private const string INDENT = " "; + + /// In place of a closing brace's index: the functions entry is not a block. + private const int NOT_A_BLOCK = -1; + + /// In place of a closing brace's index: the functions block never closes. + private const int NEVER_CLOSED = -2; + + /// What ends a word besides white space: OpenFOAM's punctuation and the start of a string. + private const string DELIMITERS = "{}();\""; + + /// + /// Latin-1 maps every byte to one character and back, so a file in any + /// ASCII-compatible encoding is written back byte for byte, save the line + /// the controller adds. + /// + private static readonly Encoding BYTES = Encoding.Latin1; + + #endregion + + #region Functions + + /// + /// Adds the response to the solve in the node's copy of + /// system/controlDict, and says in the log what was added, where + /// and why. + /// + /// The materialised case. + /// The response: a word, the name of its dictionary in system/. + /// The step's log, written whatever happens. + /// The step's outcome: 0 when the line is in; 1 with the reason in the log, the file left as it was, otherwise. + public static FoamRunOutcome Include(string caseDirectory, string response, string logPath) + { + var stopwatch = Stopwatch.StartNew(); + var log = new StringBuilder(); + var exitCode = 0; + + try + { + exitCode = Include(caseDirectory, response, log); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + log.AppendLine($"--> {CONTROL_DICT} could not be edited: {exception.Message}"); + exitCode = 1; + } + + var text = log.ToString(); + File.WriteAllText(logPath, text); + return new FoamRunOutcome(exitCode, stopwatch.Elapsed.TotalSeconds, text); + } + + /// + /// Adds #includeFunc <response> to the text of a controlDict. + /// + /// The controlDict's text. + /// The response's name. + /// + /// The edited text and the 1-based line of the added line; or a null text + /// and the refusal, one sentence naming the line of the functions + /// the controller cannot add to. + /// + public static (string? Text, int Line, string? Refusal) AddInclude(string text, string response) + { + var newLine = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var include = $"{INDENT}#{FoamAllowList.INCLUDE_FUNCTION} {response}"; + var functions = LastFunctions(text); + + if (functions == null) + return Append(text, include, newLine); + + var (keywordLine, close, closeLine) = functions.Value; + if (close == NOT_A_BLOCK) + return (null, 0, $"{CONTROL_DICT}:{keywordLine}: '{FUNCTIONS}' is not a block the controller can add a line to; write it as {FUNCTIONS} {{ ... }} for a force to be measured during the solve."); + + if (close == NEVER_CLOSED) + return (null, 0, $"{CONTROL_DICT}:{keywordLine}: '{FUNCTIONS}' opens a block that never closes."); + + var lineStart = close == 0 ? 0 : text.LastIndexOf('\n', close - 1) + 1; + if (string.IsNullOrWhiteSpace(text[lineStart..close])) + return (text[..lineStart] + include + newLine + text[lineStart..], closeLine, null); + + var end = close; + while (end > lineStart && text[end - 1] is ' ' or '\t') + end--; + + return (text[..end] + newLine + include + newLine + text[close..], closeLine + 1, null); + } + + #endregion + + #region Tools + + private static int Include(string caseDirectory, string response, StringBuilder log) + { + var path = Path.Combine(caseDirectory, "system", "controlDict"); + var line = $"#{FoamAllowList.INCLUDE_FUNCTION} {response}"; + if (!File.Exists(path)) + { + log.AppendLine($"--> the case has no {CONTROL_DICT} to add '{line}' to."); + return 1; + } + + var (edited, at, refusal) = AddInclude(File.ReadAllText(path, BYTES), response); + if (edited == null) + { + log.AppendLine($"--> {refusal}"); + return 1; + } + + File.WriteAllText(path, edited, BYTES); + log.AppendLine($"Added '{line}' at {CONTROL_DICT}:{at}, in the functions of the node's copy of the case; the case's own files are not changed."); + log.AppendLine($"The solver measures '{response}' while it solves, from system/{response}, which the controller wrote."); + log.AppendLine("Why: a force measured after the solve ( -postProcess) sees the walls a rotating zone (MRF) turns at rest, " + + "because OpenFOAM moves them only inside the solve; measured by the solver, the force is the solve's own."); + return 0; + } + + /// + /// The last top-level functions entry: the line of its keyword, the + /// index and line of its block's closing brace, or + /// / in place of the index; null when the text + /// has none. + /// + private static (int KeywordLine, int Close, int CloseLine)? LastFunctions(string text) + { + (int KeywordLine, int Close, int CloseLine)? last = null; + var depth = 0; + var line = 1; + var keywordLine = 0; + var awaiting = false; + var inBlock = false; + + var index = 0; + while (index < text.Length) + { + var current = text[index]; + var next = index + 1 < text.Length ? text[index + 1] : '\0'; + + if (current == '\n') + { + line++; + index++; + continue; + } + + if (char.IsWhiteSpace(current)) + { + index++; + continue; + } + + if (current == '/' && next == '/') + { + index = SkipTo(text, index, "\n", ref line, consume: false); + continue; + } + + if (current == '/' && next == '*') + { + index = SkipTo(text, index + 2, "*/", ref line, consume: true); + continue; + } + + if (current == '{') + { + if (awaiting) + { + awaiting = false; + inBlock = depth == 0; + } + + depth++; + index++; + continue; + } + + if (current == '}') + { + depth = Math.Max(0, depth - 1); + if (inBlock && depth == 0) + { + inBlock = false; + last = (keywordLine, index, line); + } + + index++; + continue; + } + + // Anything else after the keyword makes it an entry that is not a block. + if (awaiting) + { + awaiting = false; + last = (keywordLine, NOT_A_BLOCK, keywordLine); + } + + if (current == '#' && next == '{') + index = SkipTo(text, index + 2, "#}", ref line, consume: true); + else if (current == '"') + index = SkipString(text, index + 1, ref line); + else if (DELIMITERS.Contains(current)) + index++; + else + { + var start = index; + while (index < text.Length && !char.IsWhiteSpace(text[index]) && !DELIMITERS.Contains(text[index]) && !IsCommentAt(text, index)) + index++; + + if (depth == 0 && index - start == FUNCTIONS.Length && string.CompareOrdinal(text, start, FUNCTIONS, 0, FUNCTIONS.Length) == 0) + { + awaiting = true; + keywordLine = line; + } + } + } + + if (awaiting) + return (keywordLine, NOT_A_BLOCK, keywordLine); + + return inBlock ? (keywordLine, NEVER_CLOSED, line) : last; + } + + private static (string? Text, int Line, string? Refusal) Append(string text, string include, string newLine) + { + var prefix = text.Length == 0 || text.EndsWith('\n') ? text : text + newLine; + prefix += newLine; + var line = prefix.Count(character => character == '\n') + 3; + + return (prefix + FUNCTIONS + newLine + "{" + newLine + include + newLine + "}" + newLine, line, null); + } + + private static bool IsCommentAt(string text, int index) + { + return text[index] == '/' && index + 1 < text.Length && text[index + 1] is '/' or '*'; + } + + /// The index after (or at it, when not consumed), counting the lines passed; the text's end when it never comes. + private static int SkipTo(string text, int index, string end, ref int line, bool consume) + { + var found = text.IndexOf(end, index, StringComparison.Ordinal); + var stop = found < 0 ? text.Length : consume ? found + end.Length : found; + line += CountLines(text, index, stop); + return stop; + } + + /// The index after a string's closing quote, escapes honoured, counting the lines passed. + private static int SkipString(string text, int index, ref int line) + { + var start = index; + while (index < text.Length && text[index] != '"') + index += text[index] == '\\' ? 2 : 1; + + var stop = Math.Min(text.Length, index + 1); + line += CountLines(text, start, stop); + return stop; + } + + private static int CountLines(string text, int start, int stop) + { + var count = 0; + for (var index = start; index < stop; index++) + { + if (text[index] == '\n') + count++; + } + + return count; + } + + #endregion +} From 00b07457fd700efe7e8ef085f8319d4f93546466 Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:08 +0300 Subject: [PATCH 4/4] build(sweep): 2.1.1 carries OpenFOAM.Model 1.4.0 The host validates a case's recipe with the Model its module bundles; a recipe with the node's includeFunc step must pass the plan. Co-Authored-By: Claude Opus 5.5 --- .../Families/SweepFamilyOpenFOAMTests.cs | 19 +++++++++++++++++++ .../OutWit.Controller.Sweep.csproj | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMTests.cs b/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMTests.cs index a65802f..249c249 100644 --- a/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMTests.cs +++ b/Sweep/OutWit.Controller.Sweep.Tests/Families/SweepFamilyOpenFOAMTests.cs @@ -115,6 +115,25 @@ public async Task AResponseMayFollowTheStudysTokensAndNoOtherTest() Assert.That(await m_family.ValidateAsync(options, m_blobs), Is.EqualTo(new[] { "Response 'coeffs': token {{oc7}} is not declared by the study." })); } + [Test] + public async Task AForceTheNodeAddsToTheSolveIsAcceptedAtThePlanTest() + { + var options = Study(); + options.OpenFOAM!.Extraction = new FoamExtractionRequestData + { + Responses = [new FoamResponseSpecData { Name = "torque", Kind = FoamResponseKind.Forces, Patches = ["rotor"] }] + }; + options.OpenFOAM.Recipe!.Steps.Insert(1, new FoamStepData { Utility = "includeFunc", Arguments = ["torque"] }); + + Assert.That(await m_family.ValidateAsync(options, m_blobs), Is.Empty, "the host bundles the Model that knows the node's step"); + + options.OpenFOAM.Recipe.Steps[1].Arguments = ["streamlines"]; + Assert.That(await m_family.ValidateAsync(options, m_blobs), Is.EqualTo(new[] + { + "Step 2: includeFunc streamlines names no response of this task; the step adds only the task's own responses to the solve." + })); + } + [Test] public async Task AStudyWithoutItsBlockIsRefusedTest() { diff --git a/Sweep/OutWit.Controller.Sweep/OutWit.Controller.Sweep.csproj b/Sweep/OutWit.Controller.Sweep/OutWit.Controller.Sweep.csproj index a319549..0717b85 100644 --- a/Sweep/OutWit.Controller.Sweep/OutWit.Controller.Sweep.csproj +++ b/Sweep/OutWit.Controller.Sweep/OutWit.Controller.Sweep.csproj @@ -4,7 +4,7 @@ Sweep - 2.1.0 + 2.1.1 Host-side orchestration of parameter studies for every solver family the host knows - CalculiX decks and OpenFOAM cases - with one activity vocabulary: validates the study and its family block up front, fans each chunk out with Grid.ForEach to the family's node activity, and harvests the results into a manifest blob after every chunk, each row the sweep's verdict and the node's own result, so partial results are first-class and progress is observable while the sweep runs. Progressive chunk sizing: the first chunk as wide as the fleet, geometric growth to a cap. sweep;parameter-study;doe;calculix;ccx;openfoam;cfd;witengine;witcloud;controller sweep-solve