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 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.Tests/Model/Rules/FoamRecipeRulesTests.cs b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs index 96cc88a..231b02d 100644 --- a/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs +++ b/OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Model/Rules/FoamRecipeRulesTests.cs @@ -120,13 +120,29 @@ public void AParallelStepOnASerialUtilityIsRefusedTest() public void TheApplicationMustBeASolverAndMustRunTest() { var recipe = MotorBike(); - recipe.Application = "blockMesh"; - Assert.That(FoamRecipeRules.Validate(recipe), Has.Some.Contains("not a solver name")); + recipe.Application = "myFlowSolver"; + Assert.That(FoamRecipeRules.Validate(recipe), Has.Some.EqualTo("'myFlowSolver' is not a solver name (a solver's name ends in 'Foam').")); recipe.Application = "pisoFoam"; Assert.That(FoamRecipeRules.Validate(recipe), Has.Some.Contains("No step runs the application 'pisoFoam'")); } + [Test] + public void AUtilityAsTheApplicationIsNamedAsAUtilityTest() + { + // potentialFoam ends in 'Foam' like a solver: the reason must be + // what it is, not the shape of its name. + var recipe = MotorBike(); + recipe.Application = "potentialFoam"; + + Assert.That(FoamRecipeRules.Validate(recipe), Has.Some.EqualTo( + "'potentialFoam' is a utility on the allow-list, not a solver: the application is the solver the case runs " + + "(controlDict's 'application', such as simpleFoam); 'potentialFoam' runs as a step of its own.")); + + recipe.Application = "blockMesh"; + Assert.That(FoamRecipeRules.Validate(recipe), Has.Some.StartsWith("'blockMesh' is a utility on the allow-list, not a solver")); + } + [Test] public void NegativeNumbersAreValuesNotFlagsTest() { 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/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//