Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ public override string ToString()

/// <summary>
/// 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 <c>restore0Dir</c>.
/// process ran - a decomposition step skipped on a serial run, or one of
/// the controller's own steps (<c>restore0Dir</c>, <c>includeFunc</c>).
/// </summary>
[MemoryPackOrder(1)]
public int Ranks { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<ControllerName>OpenFOAM</ControllerName>
<Version>1.3.1</Version>
<Version>1.4.0</Version>
<Description>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.</Description>
<PackageTags>openfoam;cfd;case;sweep;parameter-study;witengine;witcloud;controller;data-types</PackageTags>
</PropertyGroup>
Expand Down
6 changes: 4 additions & 2 deletions OpenFOAM/OutWit.Controller.OpenFOAM.Model/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <response>`, 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
Expand Down
14 changes: 13 additions & 1 deletion OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamAllowList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,20 @@ public static class FoamAllowList
/// <summary>The one form of <see cref="RESTORE_INITIAL_FIELDS"/>: into the processor directories.</summary>
public const string PROCESSOR_FORM = "-processor";

/// <summary>
/// The controller's step that adds a response to the solve
/// (<c>includeFunc &lt;response&gt;</c>): one line, <c>#includeFunc
/// &lt;response&gt;</c>, in the <c>functions</c> of the node's copy of
/// <c>system/controlDict</c>, so the solver measures the response while it
/// solves. A force measured after the solve (<c>&lt;solver&gt;
/// -postProcess</c>) 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.
/// </summary>
public const string INCLUDE_FUNCTION = "includeFunc";

/// <summary>The steps the controller does itself: no kit executable, never under MPI.</summary>
public static readonly IReadOnlyList<string> BUILT_IN_STEPS = [RESTORE_INITIAL_FIELDS];
public static readonly IReadOnlyList<string> BUILT_IN_STEPS = [RESTORE_INITIAL_FIELDS, INCLUDE_FUNCTION];

/// <summary>
/// The utilities, in the order they usually run: those of version 1, then
Expand Down
31 changes: 27 additions & 4 deletions OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamCaseRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ namespace OutWit.Controller.OpenFOAM.Model.Rules;

/// <summary>
/// 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.
/// </summary>
public static class FoamCaseRules
{
Expand All @@ -26,8 +27,30 @@ public static IReadOnlyList<string> Validate(FoamCaseData? data, Func<string, bo
findings.AddRange(FoamCasePathRules.ValidateTree(data.BaseFiles));
findings.AddRange(FoamRecipeRules.Validate(data.Recipe, hasExecutable));
findings.AddRange(FoamResponseRules.Validate(data.Extraction, data.BaseFiles.Select(file => 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<string> IncludedResponses(FoamRecipeData? recipe, FoamExtractionRequestData? extraction)
{
if (recipe == null)
yield break;

var responses = new HashSet<string>((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
}
21 changes: 21 additions & 0 deletions OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamRecipeRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public static class FoamRecipeRules

private const string DECOMPOSE = "decomposePar";

private const string POST_PROCESS_FLAG = "-postProcess";

/// <summary>A flag: a dash, a letter, then letters, digits or dashes.</summary>
private static readonly Regex FLAG = new("^-[A-Za-z][A-Za-z0-9-]*$", RegexOptions.Compiled | RegexOptions.CultureInvariant);

Expand Down Expand Up @@ -78,6 +80,12 @@ public static IReadOnlyList<string> Validate(FoamRecipeData? recipe, Func<string
// The controller's restore puts the fields into processor directories: a decomposition must have made them.
if (step.Utility == FoamAllowList.RESTORE_INITIAL_FIELDS && recipe.Steps.Take(index).All(before => 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
Expand Down Expand Up @@ -176,9 +184,22 @@ private static void ValidateBuiltIn(FoamStepData step, string prefix, List<strin
if (step.Parallel)
findings.Add($"{prefix}: '{name}' is done by the controller itself, never under MPI.");

if (name == FoamAllowList.INCLUDE_FUNCTION)
{
if (step.Arguments is not [var response] || !WORD.IsMatch(response))
findings.Add($"{prefix} ({name}): the only form is '{name} <response>', 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/.");
}

/// <summary>A step that runs the application to solve, not its <c>-postProcess</c> form.</summary>
private static bool IsSolve(FoamStepData step, string application)
{
return step.Utility == application && !step.Arguments.Contains(POST_PROCESS_FLAG);
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// A field: a word that may carry dots, as every multiphase solver names
/// its phases (<c>alpha.water</c>) and a region its fields. No colon: a
/// probe writes one file per field, and a Windows node has no file named
/// <c>U:Transformed</c>.
/// </summary>
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);

/// <summary>A dictionary entry value: words, numbers, vectors and lists in parentheses - no braces, no semicolons, no code.</summary>
Expand Down Expand Up @@ -76,7 +84,7 @@ public static IReadOnlyList<string> 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)
{
Expand Down
14 changes: 13 additions & 1 deletion OpenFOAM/OutWit.Controller.OpenFOAM.Tests.FakeFoam/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>/U.
// Every copy prints "nCells: 12225" unless system/fake says
// "CELLS.<utility>=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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The size of the mesh a run ended with, read from the note OpenFOAM writes
/// into the header of every <c>owner</c> 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.
/// </summary>
[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
}
31 changes: 31 additions & 0 deletions OpenFOAM/OutWit.Controller.OpenFOAM.Tests/Fixtures/CouetteMrf/0/U
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading