From 09647dee7c31046d86b20331d927869287b0dc78 Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:51:01 +0300 Subject: [PATCH 1/4] fix(openfoam-model): a utility named as the application is refused as a utility A recipe whose application was potentialFoam was refused with "is not a solver name (a solver's name ends in 'Foam')" - a reason that does not fit a name ending in 'Foam'. An allow-listed utility in the application's place is now named for what it is and where it belongs; a name of no known shape keeps the solver-name reason. OpenFOAM.Model 1.3.1. Co-Authored-By: Claude Opus 5.5 --- .../OutWit.Controller.OpenFOAM.Model.csproj | 2 +- .../Rules/FoamRecipeRules.cs | 3 +++ .../Model/Rules/FoamRecipeRulesTests.cs | 20 +++++++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) 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 d07b89f..7f46560 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.0 + 1.3.1 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/Rules/FoamRecipeRules.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs index ce149aa..445b486 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs @@ -57,6 +57,9 @@ public static IReadOnlyList Validate(FoamRecipeData? recipe, Func Date: Sun, 27 Sep 2026 08:39:18 +0300 Subject: [PATCH 2/4] fix(openfoam): a response row that is not at the run's final time is not reported The extractor read the last row of every file in a function object's latest time directory, whatever its time. A function object that stops writing before the run ends - a write interval that misses the end time, a timeEnd - left its last row at an earlier time, and that stale value was reported as the variant's response. Given the run's final time (the solver log's), a file whose last row is earlier is now left out, and a sentence names it in log.responses beside the step logs, where it travels with the logs, and in the node's log. An unknown final time checks nothing. OpenFOAM 1.0.6. Co-Authored-By: Claude Opus 5.5 --- .../Extraction/FoamResponseExtractorTests.cs | 45 ++++++++++++++ .../Extraction/FoamResponseExtractor.cs | 59 ++++++++++++++++++- .../OutWit.Controller.OpenFOAM.csproj | 2 +- .../Runtime/FoamCaseSession.cs | 14 ++++- 4 files changed, 115 insertions(+), 5 deletions(-) diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamResponseExtractorTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamResponseExtractorTests.cs index 240e603..8205ac0 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamResponseExtractorTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Extraction/FoamResponseExtractorTests.cs @@ -72,6 +72,51 @@ public void ValuesAreNamedByResponseAndColumnAndTheTimeColumnIsSkippedTest() Assert.That(names.Count(name => name.StartsWith("absent", StringComparison.Ordinal)), Is.EqualTo(0), "a response whose function object wrote nothing contributes nothing"); } + [Test] + public void ARowBeforeTheFinalTimeIsNotReportedTest() + { + // A function object that stopped writing before the run ended: its + // last row is not the final state, and a stale value is worse than none. + Write("postProcessing/coeffs/0/coefficient.dat", COEFFICIENT_DAT); + var request = new FoamExtractionRequestData { Responses = [new FoamResponseSpecData { Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs }] }; + var notes = new List(); + + var row = FoamResponseExtractor.Extract(m_case, request, 250, notes); + + Assert.That(row.Values, Is.Empty); + Assert.That(notes, Is.EqualTo(new[] { "coeffs: the last row of coefficient.dat is at time 200, the run ended at 250 - not reported." })); + } + + [Test] + public void ARowAtTheFinalTimeIsReportedTest() + { + Write("postProcessing/coeffs/0/coefficient.dat", COEFFICIENT_DAT); + Write("postProcessing/probes/0/p", PROBES_P); + var request = new FoamExtractionRequestData + { + Responses = + [ + new FoamResponseSpecData { Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs }, + new FoamResponseSpecData { Name = "probes", Kind = FoamResponseKind.Probe, Fields = ["p"] } + ] + }; + var notes = new List(); + + // Transient times carry the last digit's noise of the time loop. + Assert.That(FoamResponseExtractor.Extract(m_case, request, 200, notes).Values.Select(value => value.Name), Does.Contain("coeffs.Cd")); + Assert.That(FoamResponseExtractor.Extract(m_case, request, 0.20000000000000004, notes).Values.Select(value => value.Name), Does.Contain("probes.c1")); + Assert.That(notes, Has.Count.EqualTo(2), "each call judges its own final time: coeffs at 200 is stale against 0.2, probes at 0.2 against 200"); + } + + [Test] + public void TheNotesAreWrittenBesideTheStepLogsTest() + { + FoamResponseExtractor.WriteNotes(m_case, ["coeffs: the last row of coefficient.dat is at time 200, the run ended at 250 - not reported."]); + FoamResponseExtractor.WriteNotes(m_case, []); + + Assert.That(File.ReadAllText(Path.Combine(m_case, "log.responses")), Does.Contain("coeffs: the last row")); + } + [Test] public void AMultiFieldMinMaxRowIsReadWholeTest() { diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamResponseExtractor.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamResponseExtractor.cs index 4ecd5db..17bbe7b 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamResponseExtractor.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Extraction/FoamResponseExtractor.cs @@ -9,7 +9,10 @@ namespace OutWit.Controller.OpenFOAM.Extraction; /// its latest time directory, each column a value named /// <response>.<column> (with the file's name in between when /// a function object writes more than one file). The time column is left out; -/// it is the same for every column and the result carries it once. +/// it is the same for every column and the result carries it once. Given the +/// run's final time, a file whose last row is earlier - a function object +/// that stopped writing before the run ended - is not reported: a stale value +/// is worse than none, and a note names it. /// public static class FoamResponseExtractor { @@ -19,6 +22,11 @@ public static class FoamResponseExtractor private const string TIME_COLUMN = "Time"; + private const string NOTES_FILE = "log.responses"; + + /// Relative tolerance of "the same time": a transient time loop carries its last digit's noise. + private const double TIME_TOLERANCE = 1e-9; + #endregion #region Functions @@ -30,6 +38,19 @@ public static class FoamResponseExtractor /// The request; null yields an empty row. /// The row; a response whose files are missing contributes nothing. public static FoamResponseRowData Extract(string caseDirectory, FoamExtractionRequestData? request) + { + return Extract(caseDirectory, request, null, null); + } + + /// + /// Extracts the requested responses whose last row is at the run's final time. + /// + /// The case root. + /// The request; null yields an empty row. + /// The time the run ended at; null skips the check. + /// Receives one sentence per file left out; null discards them. + /// The row; a response whose files are missing or stale contributes nothing. + public static FoamResponseRowData Extract(string caseDirectory, FoamExtractionRequestData? request, double? finalTime, ICollection? notes) { var row = new FoamResponseRowData(); if (request == null) @@ -51,6 +72,13 @@ public static FoamResponseRowData Extract(string caseDirectory, FoamExtractionRe if (table == null) continue; + if (finalTime is { } final && IsStale(table.Value.Columns, table.Value.Values, final, out var time)) + { + notes?.Add(string.Create(CultureInfo.InvariantCulture, + $"{response.Name}: the last row of {Path.GetFileName(file)} is at time {time}, the run ended at {final} - not reported.")); + continue; + } + var stem = Path.GetFileNameWithoutExtension(file); var prefix = files.Count > 1 ? $"{response.Name}.{stem}" : response.Name; @@ -72,6 +100,20 @@ public static FoamResponseRowData Extract(string caseDirectory, FoamExtractionRe return row; } + /// + /// Writes the notes of an extraction beside the step logs (log.responses), + /// where they travel with the logs; no notes, no file. + /// + /// The case root. + /// The notes of . + public static void WriteNotes(string caseDirectory, IReadOnlyCollection notes) + { + if (notes.Count == 0) + return; + + File.WriteAllLines(Path.Combine(caseDirectory, NOTES_FILE), notes); + } + /// /// The time directory with the largest numeric name under a function /// object's output directory. @@ -103,4 +145,19 @@ public static FoamResponseRowData Extract(string caseDirectory, FoamExtractionRe } #endregion + + #region Tools + + // A last row whose time column is not the final time. + private static bool IsStale(IReadOnlyList columns, IReadOnlyList values, double finalTime, out double time) + { + time = double.NaN; + if (columns.Count == 0 || values.Count == 0 || !string.Equals(columns[0], TIME_COLUMN, StringComparison.OrdinalIgnoreCase)) + return false; + + time = values[0]; + return System.Math.Abs(time - finalTime) > TIME_TOLERANCE * System.Math.Max(1.0, System.Math.Abs(finalTime)); + } + + #endregion } diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj b/OpenFOAM/OutWit.Controller.OpenFOAM/OutWit.Controller.OpenFOAM.csproj index 66a457f..e58217f 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.5 + 1.0.6 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/Runtime/FoamCaseSession.cs b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs index 9adf60e..f79b442 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/Runtime/FoamCaseSession.cs @@ -110,7 +110,7 @@ public async Task RunAsync(FoamTaskData task, CancellationToken if (report.Succeeded) { - result.ResponseRow = ExtractResponses(caseDirectory, data.Extraction); + result.ResponseRow = ExtractResponses(caseDirectory, data.Extraction, result.FinalTime); await UploadArtifactAsync(result, caseDirectory, scratch.UsablePath, data.ArtifactPolicy, cancellationToken); } else if (data.ArtifactPolicy?.Logs == true) @@ -164,13 +164,21 @@ private void ReadFacts(FoamResultData result, string caseDirectory, FoamRecipeDa result.CellCount = cells; } - private FoamResponseRowData ExtractResponses(string caseDirectory, FoamExtractionRequestData? extraction) + private FoamResponseRowData ExtractResponses(string caseDirectory, FoamExtractionRequestData? extraction, double finalTime) { // A parsing surprise must not turn a finished run into a failure: the // row degrades to empty and the run's other facts stand. try { - return FoamResponseExtractor.Extract(caseDirectory, extraction); + // A response whose last row is not the run's final time is left + // out and named in log.responses, which travels with the logs; + // an unknown final time (no solver log) checks nothing. + var notes = new List(); + var row = FoamResponseExtractor.Extract(caseDirectory, extraction, finalTime > 0 ? finalTime : null, notes); + foreach (var note in notes) + Logger?.LogWarning("Foam.Run: {Note}", note); + FoamResponseExtractor.WriteNotes(caseDirectory, notes); + return row; } catch (Exception e) { From edcaae12ca2d9b33abe6f85e53bee32f3f1b3f49 Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:39:44 +0300 Subject: [PATCH 3/4] docs(openfoam): the response rows the node reports and log.responses Co-Authored-By: Claude Opus 5.5 --- OpenFOAM/OutWit.Controller.OpenFOAM/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md index d635d4f..af7a4ab 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM/README.md +++ b/OpenFOAM/OutWit.Controller.OpenFOAM/README.md @@ -46,6 +46,12 @@ leftovers of an earlier run in the base case (`log.*`, `postProcessing/`, collides with a file the case ships under `system/` is refused rather than overwritten. +A response is read from the last row of each file in its function object's +latest `postProcessing//