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 @@ -5,7 +5,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<ControllerName>OpenFOAM</ControllerName>
<Version>1.2.0</Version>
<Version>1.3.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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ public static class FoamResponseRules

private const string SYSTEM_PREFIX = "system/";

/// <summary>
/// What a placeholder counts as when a value is checked before the
/// variant supplies it: a number. The node checks the value again once it
/// is substituted.
/// </summary>
private const string TOKEN_STAND_IN = "0";

private static readonly Regex WORD = 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);
Expand Down Expand Up @@ -75,7 +82,7 @@ public static IReadOnlyList<string> Validate(FoamExtractionRequestData? request,
{
if (!WORD.IsMatch(parameter.Name))
findings.Add($"{prefix}: parameter '{parameter.Name}' is not a keyword.");
else if (!VALUE.IsMatch(parameter.Value))
else if (!VALUE.IsMatch(FoamTemplating.TOKEN.Replace(parameter.Value, TOKEN_STAND_IN)))
findings.Add($"{prefix}: the value of '{parameter.Name}' is not a plain dictionary value.");
}
}
Expand Down
23 changes: 23 additions & 0 deletions OpenFOAM/OutWit.Controller.OpenFOAM.Model/Rules/FoamTemplating.cs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,29 @@ public static IReadOnlyList<string> CheckCoverage(IReadOnlyList<string> tokens,
return findings;
}

/// <summary>
/// Checks the placeholders of a study's responses: a response parameter
/// may follow a swept value (<c>magUInf {{oc1}}</c> beside the swept
/// speed), but only a token the study declares - otherwise every variant
/// would be refused on its node.
/// </summary>
/// <param name="tokens">The study's declared tokens.</param>
/// <param name="request">The responses; null asks for none.</param>
/// <returns>Findings, one sentence each; empty when every response token is declared.</returns>
public static IReadOnlyList<string> CheckResponseCoverage(IReadOnlyList<string> tokens, FoamExtractionRequestData? request)
{
var declared = new HashSet<string>(tokens, StringComparer.Ordinal);
var findings = new List<string>();

foreach (var response in request?.Responses ?? [])
{
foreach (var token in response.Parameters.SelectMany(parameter => LeftoverTokens(parameter.Value)).Distinct(StringComparer.Ordinal).Where(token => !declared.Contains(token)))
findings.Add($"Response '{response.Name}': token {token} is not declared by the study.");
}

return findings;
}

private static Dictionary<string, string> ValuesOf(IReadOnlyList<FoamTokenValueData> substitutions, Func<string, string> view)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,44 @@ public void AResponseNamedLikeAFileTheCaseShipsIsRefusedAndNothingIsWrittenTest(
Assert.That(File.Exists(Path.Combine(m_case, "system", "inletP")), Is.False, "the other responses are not written either");
}

[Test]
public void AResponseParameterTakesTheVariantsValueTest()
{
var request = MotorBikeRequest();
request.Responses[0].Parameters.Single(parameter => parameter.Name == "magUInf").Value = "{{oc1}}";

var findings = FoamFunctionObjectWriter.Write(m_case, request, [new FoamTokenValueData { Token = "{{oc1}}", Value = "25" }]);

Assert.That(findings, Is.Empty);
var coefficients = File.ReadAllText(Path.Combine(m_case, "system", "coeffs"));
Assert.That(coefficients, Does.Contain("magUInf 25;"));
Assert.That(coefficients, Does.Not.Contain("{{"));
}

[Test]
public void AValueThatIsNotPlainAfterSubstitutionIsRefusedTest()
{
var request = MotorBikeRequest();
request.Responses[0].Parameters.Single(parameter => parameter.Name == "magUInf").Value = "{{oc1}}";

var findings = FoamFunctionObjectWriter.Write(m_case, request, [new FoamTokenValueData { Token = "{{oc1}}", Value = "25; #calc \"1\"" }]);

Assert.That(findings, Is.EqualTo(new[] { "Response 'coeffs': the value of 'magUInf' is not a plain dictionary value." }));
Assert.That(File.Exists(Path.Combine(m_case, "system", "coeffs")), Is.False);
}

[Test]
public void ATokenWithoutAValueInTheVariantIsNamedTest()
{
var request = MotorBikeRequest();
request.Responses[0].Parameters.Single(parameter => parameter.Name == "magUInf").Value = "{{oc2}}";

var findings = FoamFunctionObjectWriter.Write(m_case, request, [new FoamTokenValueData { Token = "{{oc1}}", Value = "25" }]);

Assert.That(findings, Is.EqualTo(new[] { "Response 'coeffs': token {{oc2}} has no value in this variant." }));
Assert.That(File.Exists(Path.Combine(m_case, "system", "coeffs")), Is.False);
}

[Test]
public void ANullOrEmptyRequestWritesNothingTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,26 @@ public void AQuotedSelectorIsPlainRegularExpressionTextTest()
Assert.That(FoamResponseRules.IsQuotedRegex("\"a;b\""), Is.False, "a semicolon ends a dictionary entry");
}

[Test]
public void APlaceholderStandsForAValueTheSweepSuppliesTest()
{
static FoamExtractionRequestData Coefficients(string value) => new()
{
Responses =
[
new FoamResponseSpecData
{
Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs, Patches = ["body"],
Parameters = [new FoamNamedValueData { Name = "magUInf", Value = value }]
}
]
};

Assert.That(FoamResponseRules.Validate(Coefficients("{{oc1}}")), Is.Empty, "a speed swept with the case");
Assert.That(FoamResponseRules.Validate(Coefficients("({{oc1}} 0 0)")), Is.Empty, "a placeholder inside a vector");
Assert.That(FoamResponseRules.Validate(Coefficients("{{oc1}};#calc")), Has.Some.Contains("the value of 'magUInf' is not a plain dictionary value"));
Assert.That(FoamResponseRules.Validate(Coefficients("{{name}}")), Has.Some.Contains("not a plain dictionary value"), "only the sweep's own token shape");
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,26 @@ public void AStudyWithoutTokensOverAnUntemplatedCaseIsCoveredTest()
Assert.That(FoamTemplating.CheckCoverage([], []), Is.Empty);
}

[Test]
public void AResponseTokenTheStudyDoesNotDeclareIsAFindingTest()
{
var request = new FoamExtractionRequestData
{
Responses =
[
new FoamResponseSpecData
{
Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs, Patches = ["body"],
Parameters = [new FoamNamedValueData { Name = "magUInf", Value = "{{oc1}}" }, new FoamNamedValueData { Name = "lRef", Value = "{{oc3}}" }]
}
]
};

Assert.That(FoamTemplating.CheckResponseCoverage(["{{oc1}}", "{{oc3}}"], request), Is.Empty);
Assert.That(FoamTemplating.CheckResponseCoverage(["{{oc1}}"], request),
Is.EqualTo(new[] { "Response 'coeffs': token {{oc3}} is not declared by the study." }));
Assert.That(FoamTemplating.CheckResponseCoverage(["{{oc1}}"], null), Is.Empty);
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,34 @@ private FoamTaskData PitzDaily(int threads, bool parallel)
};
}

/// <summary>
/// The task with force coefficients on the lower wall measured after the
/// solve, the reference speed given as written (a number or a token).
/// </summary>
private static FoamTaskData WithCoefficients(FoamTaskData task, string magUInf)
{
task.Case!.Extraction!.Responses.Add(new FoamResponseSpecData
{
Name = "coeffs",
Kind = FoamResponseKind.ForceCoeffs,
Patches = ["lowerWall"],
Parameters =
[
new FoamNamedValueData { Name = "rho", Value = "rhoInf" },
new FoamNamedValueData { Name = "rhoInf", Value = "1" },
new FoamNamedValueData { Name = "CofR", Value = "(0 0 0)" },
new FoamNamedValueData { Name = "liftDir", Value = "(0 1 0)" },
new FoamNamedValueData { Name = "dragDir", Value = "(1 0 0)" },
new FoamNamedValueData { Name = "pitchAxis", Value = "(0 0 1)" },
new FoamNamedValueData { Name = "magUInf", Value = magUInf },
new FoamNamedValueData { Name = "lRef", Value = "1" },
new FoamNamedValueData { Name = "Aref", Value = "1" }
]
});
task.Case.Recipe!.Steps.Add(new FoamStepData { Utility = "simpleFoam", Arguments = ["-postProcess", "-func", "coeffs", "-latestTime"] });
return task;
}

/// <summary>
/// The first response value whose name starts with the prefix. The column
/// names are the function objects' own (<c>areaAverage(p)</c>,
Expand Down Expand Up @@ -196,6 +224,26 @@ public async Task PitzDailyRunsOnTwoRanksAndAgreesWithTheSerialRunTest()
Assert.That(Value(parallel, "pRange.", "max"), Is.EqualTo(Value(serial, "pRange.", "max")).Within(2).Percent);
}

[Test]
public async Task AForceCoefficientFollowsTheSweptReferenceSpeedTest()
{
var session = new FoamCaseSession(m_kit, m_blobs, new WitTempStorageDefault(m_storage));

// The same flow twice; the coefficients' reference speed written as
// 10, then taken from the variant as 20. OpenFOAM divides the same
// forces by the square of the reference: a quarter of the first Cd.
var written = await session.RunAsync(WithCoefficients(PitzDaily(threads: 1, parallel: false), "10"));
var followed = WithCoefficients(PitzDaily(threads: 1, parallel: false), "{{oc2}}");
followed.Substitutions.Add(new FoamTokenValueData { Token = "{{oc2}}", Value = "20" });
var swept = await session.RunAsync(followed);
PrintRow(written);
PrintRow(swept);

Assert.That(swept.Rejections, Is.Empty);
Assert.That(swept.ExitCode, Is.EqualTo(0), swept.LogTail);
Assert.That(Value(swept, "coeffs.", "Cd"), Is.EqualTo(Value(written, "coeffs.", "Cd") / 4).Within(0.01).Percent);
}

[Test]
public async Task ADivergingVariantComesBackAsAFailedResultTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,24 @@ public static class FoamFunctionObjectWriter
/// </summary>
/// <param name="caseDirectory">The materialised case.</param>
/// <param name="request">The request; null writes nothing.</param>
/// <param name="substitutions">The variant's values: a parameter that names a token (<c>magUInf {{oc1}}</c>) takes its value.</param>
/// <returns>Findings, one sentence each; nothing is written when there is any.</returns>
public static IReadOnlyList<string> Write(string caseDirectory, FoamExtractionRequestData? request)
public static IReadOnlyList<string> Write(string caseDirectory, FoamExtractionRequestData? request, IReadOnlyList<FoamTokenValueData>? substitutions = null)
{
if (request == null || request.Responses.Count == 0)
return [];

// A parameter that follows a swept value takes this variant's value;
// the rules then judge the value itself, not the placeholder.
request = Instantiate(request, substitutions ?? []);

var findings = FoamResponseRules.Validate(request).ToList();
foreach (var response in request.Responses)
{
foreach (var token in response.Parameters.SelectMany(parameter => FoamTemplating.LeftoverTokens(parameter.Value)).Distinct(StringComparer.Ordinal))
findings.Add($"Response '{response.Name}': token {token} has no value in this variant.");
}

var system = Path.Combine(caseDirectory, "system");

// A response never overwrites a file the user shipped under the same name.
Expand Down Expand Up @@ -110,6 +121,15 @@ public static string Render(FoamResponseSpecData response)
return text.ToString();
}

private static FoamExtractionRequestData Instantiate(FoamExtractionRequestData request, IReadOnlyList<FoamTokenValueData> substitutions)
{
var instantiated = request.Clone();
foreach (var parameter in instantiated.Responses.SelectMany(response => response.Parameters))
parameter.Value = FoamTemplating.Substitute(parameter.Value, substitutions);

return instantiated;
}

private static string List(string keyword, IReadOnlyList<string> items)
{
return $"{keyword,-16}({string.Join(' ', items)});\n";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<PropertyGroup>
<ControllerName>OpenFOAM</ControllerName>
<Version>1.0.4</Version>
<Version>1.0.5</Version>
<Description>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.</Description>
<PackageTags>openfoam;cfd;solver;simulation;witengine;witcloud;controller</PackageTags>
<ControllerFeatures>foam-run</ControllerFeatures>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public async Task<FoamResultData> RunAsync(FoamTaskData task, CancellationToken
if (rejections.Count == 0)
{
rejections.AddRange(await FoamCaseMaterializer.MaterializeAsync(task, caseDirectory, BlobService, cancellationToken));
rejections.AddRange(FoamFunctionObjectWriter.Write(caseDirectory, task.Case?.Extraction));
rejections.AddRange(FoamFunctionObjectWriter.Write(caseDirectory, task.Case?.Extraction, task.Substitutions));
}

if (rejections.Count == 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,28 @@ public async Task TokenCoverageIsCheckedOverTheTemplatedFilesOnlyTest()
}));
}

[Test]
public async Task AResponseMayFollowTheStudysTokensAndNoOtherTest()
{
var options = Study();
options.OpenFOAM!.Extraction = new FoamExtractionRequestData
{
Responses =
[
new FoamResponseSpecData
{
Name = "coeffs", Kind = FoamResponseKind.ForceCoeffs, Patches = ["body"],
Parameters = [new FoamNamedValueData { Name = "magUInf", Value = "{{oc1}}" }]
}
]
};

Assert.That(await m_family.ValidateAsync(options, m_blobs), Is.Empty, "the speed's own token");

options.OpenFOAM.Extraction.Responses[0].Parameters[0].Value = "{{oc7}}";
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 AStudyWithoutItsBlockIsRefusedTest()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ public async Task<IReadOnlyList<string>> ValidateAsync(SweepOptionsData options,
templated.Add((file.RelativePath, FoamCaseText.FromBytes(await File.ReadAllBytesAsync(path))));
}

findings.AddRange(FoamTemplating.CheckCoverage(options.Parameters.Select(parameter => parameter.Token).ToList(), templated));
var tokens = options.Parameters.Select(parameter => parameter.Token).ToList();
findings.AddRange(FoamTemplating.CheckCoverage(tokens, templated));
findings.AddRange(FoamTemplating.CheckResponseCoverage(tokens, data.Extraction));
return findings;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<PropertyGroup>
<ControllerName>Sweep</ControllerName>
<Version>2.0.3</Version>
<Version>2.0.4</Version>
<Description>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.</Description>
<PackageTags>sweep;parameter-study;doe;calculix;ccx;openfoam;cfd;witengine;witcloud;controller</PackageTags>
<ControllerFeatures>sweep-solve</ControllerFeatures>
Expand Down
Loading