calibration: a fit that hands back the file's own tilt is not a calibration

A powder calibration is run because the file's geometry is in doubt, so a fit that
quietly returns part of that file has answered nothing - and it is indistinguishable
from one that worked, down to the residual and the sigmas arranged around it.

On one of four LaB6 exposures of one detector the tilt came out at 2.93x its own
sigma, a hundredth under the significance gate, so it was declined and pinned - at the
master's hardcoded rot1 -0.08, rot2 -0.22 deg. That is eight times the tilt just
refused, on no evidence, and worth 10 px of PONI at 190 mm. rugnux printed it to four
decimal places, wrote the .poni, and exited 0.

Judge the result on provenance instead of on any residual: a geometry is a measurement
only if every parameter in it came from this data. Two ways out of the fits do not
qualify - a covariance that never conditioned, so the fit cannot say what it
determined, and a declined tilt pinned at a non-zero value from the file. A declined
tilt over a file stating no tilt still qualifies, because reporting no tilt is then
exactly what was measured; so does --no-refine-tilt, because a hold that was asked for
is a stated choice and not a silent substitution.

No single number separates the four. rms is 2.465 px against 1.44-1.64; the
significance of all four lies between 2.93 and 4.47, so the gate is nearly a coin flip
at these distances and moving it would only recalibrate on one population; and the
failed fit has the TIGHTEST parameter sigmas of the set, because pinning the tilt
removes the tilt/centre correlation that inflates a good fit's. The spot cross-check
reads 13.5 px against 0.98-2.66, but 10.4 px of that is the pinned tilt moving the
PONI - the same defect one step downstream, not independent evidence.

On a failure rugnux says so, writes no .poni - a PONI file states where the detector is
and has no field in which to say it does not know - writes the JSON with converged
false and the reason beside it, and exits non-zero. The re-binning pass now prefers a
converged refit over a non-converged one whatever its residual, so a tilt an earlier
pass measured is not what a later one gets pinned at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 09:37:46 +02:00
co-authored by Claude Opus 5
parent 4bb3d44983
commit ef5da29319
13 changed files with 307 additions and 6 deletions
@@ -21,6 +21,10 @@ namespace org::openapitools::server::model
Powder_calibration_quality::Powder_calibration_quality()
{
m_Converged = false;
m_ConvergedIsSet = false;
m_Not_converged_reason = "";
m_Not_converged_reasonIsSet = false;
m_Calibrant = "";
m_CalibrantIsSet = false;
m_Method = "";
@@ -67,7 +71,7 @@ bool Powder_calibration_quality::validate(std::stringstream& msg, const std::str
bool success = true;
const std::string _pathPrefix = pathPrefix.empty() ? "Powder_calibration_quality" : pathPrefix;
return success;
}
@@ -77,6 +81,12 @@ bool Powder_calibration_quality::operator==(const Powder_calibration_quality& rh
((!convergedIsSet() && !rhs.convergedIsSet()) || (convergedIsSet() && rhs.convergedIsSet() && isConverged() == rhs.isConverged())) &&
((!notConvergedReasonIsSet() && !rhs.notConvergedReasonIsSet()) || (notConvergedReasonIsSet() && rhs.notConvergedReasonIsSet() && getNotConvergedReason() == rhs.getNotConvergedReason())) &&
((!calibrantIsSet() && !rhs.calibrantIsSet()) || (calibrantIsSet() && rhs.calibrantIsSet() && getCalibrant() == rhs.getCalibrant())) &&
@@ -126,6 +136,10 @@ bool Powder_calibration_quality::operator!=(const Powder_calibration_quality& rh
void to_json(nlohmann::json& j, const Powder_calibration_quality& o)
{
j = nlohmann::json::object();
if(o.convergedIsSet())
j["converged"] = o.m_Converged;
if(o.notConvergedReasonIsSet())
j["not_converged_reason"] = o.m_Not_converged_reason;
if(o.calibrantIsSet())
j["calibrant"] = o.m_Calibrant;
if(o.methodIsSet())
@@ -157,6 +171,16 @@ void to_json(nlohmann::json& j, const Powder_calibration_quality& o)
void from_json(const nlohmann::json& j, Powder_calibration_quality& o)
{
if(j.find("converged") != j.end())
{
j.at("converged").get_to(o.m_Converged);
o.m_ConvergedIsSet = true;
}
if(j.find("not_converged_reason") != j.end())
{
j.at("not_converged_reason").get_to(o.m_Not_converged_reason);
o.m_Not_converged_reasonIsSet = true;
}
if(j.find("calibrant") != j.end())
{
j.at("calibrant").get_to(o.m_Calibrant);
@@ -225,6 +249,40 @@ void from_json(const nlohmann::json& j, Powder_calibration_quality& o)
}
bool Powder_calibration_quality::isConverged() const
{
return m_Converged;
}
void Powder_calibration_quality::setConverged(bool const value)
{
m_Converged = value;
m_ConvergedIsSet = true;
}
bool Powder_calibration_quality::convergedIsSet() const
{
return m_ConvergedIsSet;
}
void Powder_calibration_quality::unsetConverged()
{
m_ConvergedIsSet = false;
}
std::string Powder_calibration_quality::getNotConvergedReason() const
{
return m_Not_converged_reason;
}
void Powder_calibration_quality::setNotConvergedReason(std::string const& value)
{
m_Not_converged_reason = value;
m_Not_converged_reasonIsSet = true;
}
bool Powder_calibration_quality::notConvergedReasonIsSet() const
{
return m_Not_converged_reasonIsSet;
}
void Powder_calibration_quality::unsetNot_converged_reason()
{
m_Not_converged_reasonIsSet = false;
}
std::string Powder_calibration_quality::getCalibrant() const
{
return m_Calibrant;
@@ -60,6 +60,20 @@ public:
/////////////////////////////////////////////
/// Powder_calibration_quality members
/// <summary>
/// Whether the geometry beside this is a measurement of the data at all. False means at least one of its parameters was carried over from the input file rather than fitted - which looks exactly like a good fit in every other field here - and see not_converged_reason for which. rugnux --mode calibration writes no .poni file in that case, a PONI file having no field in which to say it, and exits non-zero.
/// </summary>
bool isConverged() const;
void setConverged(bool const value);
bool convergedIsSet() const;
void unsetConverged();
/// <summary>
/// What made converged false, in words. Absent when the calibration converged.
/// </summary>
std::string getNotConvergedReason() const;
void setNotConvergedReason(std::string const& value);
bool notConvergedReasonIsSet() const;
void unsetNot_converged_reason();
/// <summary>
/// The powder standard the rings were fitted to, or the unit cell given in its place
/// </summary>
@@ -155,6 +169,10 @@ public:
friend void to_json(nlohmann::json& j, const Powder_calibration_quality& o);
friend void from_json(const nlohmann::json& j, Powder_calibration_quality& o);
protected:
bool m_Converged;
bool m_ConvergedIsSet;
std::string m_Not_converged_reason;
bool m_Not_converged_reasonIsSet;
std::string m_Calibrant;
bool m_CalibrantIsSet;
std::string m_Method;
+11
View File
@@ -1756,6 +1756,17 @@ components:
wrong - the wrong standard named, a header too far out for the rings to be found - looks
exactly like one that has not until these are read.
properties:
converged:
type: boolean
description: |
Whether the geometry beside this is a measurement of the data at all. False means at least
one of its parameters was carried over from the input file rather than fitted - which looks
exactly like a good fit in every other field here - and see not_converged_reason for which.
rugnux --mode calibration writes no .poni file in that case, a PONI file having no field in
which to say it, and exits non-zero.
not_converged_reason:
type: string
description: What made converged false, in words. Absent when the calibration converged.
calibrant:
type: string
description: The powder standard the rings were fitted to, or the unit cell given in its place
File diff suppressed because one or more lines are too long
+1
View File
@@ -18,6 +18,7 @@
* `rugnux` reports the resolution the CC1/2 fit reached and the strong-direction diffraction limit.
* `rugnux` reports twinning measured before and after the space group was decided, and no longer reports it when the L-test contradicts it.
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, holding the geometry as a `jfjoch_broker` `dataset_settings` body.
* `rugnux --mode calibration` says when a fit is not a measurement: it writes no `.poni`, exits non-zero, and records `converged` in the `.json`.
* `rugnux --no-refine-tilt` holds the detector tilt at the value in the file instead of zeroing it.
* `rugnux` refines only the detector-tilt component the data determine, holding the one along the rotation axis, so a beam-centre error is no longer reported as a tilt.
* `jfjoch_writer` writes `direct_beam_x`/`direct_beam_y` in the HDF5 master - where the undeflected beam lands - beside the `beam_center_x`/`beam_center_y` PONI.
+11
View File
@@ -62,6 +62,17 @@ standard error that implies on the centre. That error is *formal*: it measures t
points, not whether the rings themselves are trustworthy, so it stays small when a fit goes wrong
for a structural reason — one visible ring, or ice that is textured rather than smooth.
A calibration is run because the header is in doubt, so a fit that quietly hands part of that header
back has answered nothing — and it looks exactly like a fit that worked, down to the residual and the
sigmas around it. **`calibration.converged`** in the JSON says which of the two a file is. It is false
when the tilt was declined and pinned at a *non-zero* header value — the rings said they could not tell
a tilt from a shift of the beam centre, and the angle written in its place has no more support than the
one refused — or when the fit's covariance never conditioned, so it cannot say what it determined. In
either case rugnux prints the reason, writes the `.json` with `converged` false and
`not_converged_reason` beside it, exits non-zero, and writes **no `.poni`**: a PONI file states where
the detector is and has no field in which to say that it does not know. A declined tilt over a header
that states *no* tilt is not this — reporting no tilt is then exactly what the fit measured.
`--no-refine-tilt` holds rot1/rot2 where the header put them and fits only the centre and the
distance. The tilt is real and worth measuring, but a program that has nowhere to put one — XDS
takes a detector normal to the beam — is better given a geometry that was measured with the tilt
+13
View File
@@ -1201,6 +1201,19 @@ export type powder_calibration_spot_check = {
*
*/
export type powder_calibration_quality = {
/**
* Whether the geometry beside this is a measurement of the data at all. False means at least
* one of its parameters was carried over from the input file rather than fitted - which looks
* exactly like a good fit in every other field here - and see not_converged_reason for which.
* rugnux --mode calibration writes no .poni file in that case, a PONI file having no field in
* which to say it, and exits non-zero.
*
*/
converged?: boolean;
/**
* What made converged false, in words. Absent when the calibration converged.
*/
not_converged_reason?: string;
/**
* The powder standard the rings were fitted to, or the unit cell given in its place
*/
+2
View File
@@ -494,6 +494,8 @@ export const zPowderCalibrationSpotCheck = z.object({
*
*/
export const zPowderCalibrationQuality = z.object({
converged: z.boolean().optional(),
not_converged_reason: z.string().optional(),
calibrant: z.string().optional(),
method: z.enum(['rings', 'spots']).optional(),
ring_points: z.coerce.bigint().min(BigInt('-9223372036854775808'), { error: 'Invalid value: Expected int64 to be >= -9223372036854775808' }).max(BigInt('9223372036854775807'), { error: 'Invalid value: Expected int64 to be <= 9223372036854775807' }).optional(),
@@ -58,6 +58,54 @@ CalibrationResult Summarize(const DiffractionGeometry &fitted,
} // namespace
// Is the geometry about to be returned a measurement of this data, or does it carry a number out of the
// input file wearing the appearance of one? A calibration is run precisely because that file's geometry
// is in doubt, so handing part of it back - to four decimal places, with a residual and a set of sigmas
// arranged around it - is the one failure a caller cannot see. Nothing downstream can see it either: a
// .poni states where the detector is and has no field for how that was arrived at.
//
// Two ways out of the fits below produce such a geometry. Neither is a threshold; both are statements
// about where a number came from.
//
// The covariance never conditioned. RingOptimizer reports valid = false when the normal matrix is
// singular at the solution - some direction in parameter space costs the fit nothing at all - and a
// fit that cannot say what it determined has not determined it.
//
// The tilt was declined, and the value pinned in its place is not zero. Declining is the statement
// that these rings cannot tell a tilt from a shift of the beam centre. Pinning then writes the INPUT
// FILE's tilt into the answer, which those same rings support no better, and which is not even the
// tilt that was just refused: measured on a 190 mm powder exposure, the gate rejected a tilt of
// 0.026 deg at 2.9 sigma and returned the file's 0.22 deg - eight times larger, on no evidence, and
// worth 10 px of PONI at that distance. Where the file's tilt is zero the two agree and the output is
// honest: the fit found no tilt and reports none, which is what a long-distance fit should do. Where
// it is not zero, the answer carries an angle from the header at whatever size the header stated.
//
// --no-refine-tilt is not this. There the user asked for the file's tilt to be held, and a held tilt
// that was asked for is a stated choice rather than a silent substitution.
void JudgeCalibration(CalibrationResult &result, const DiffractionGeometry &header,
bool refine_tilt) {
if (!result.uncertainty.valid) {
result.converged = false;
result.reason = "the fit is degenerate at its solution - its covariance does not condition, so "
"it cannot say what it determined";
return;
}
if (refine_tilt && !result.tilt_refined
&& (header.GetPoniRot1_rad() != 0.0f || header.GetPoniRot2_rad() != 0.0f)) {
constexpr double RAD_TO_DEG = 180.0 / PI;
const double poni_pxl = std::hypot(header.GetPoniRot1_rad(), header.GetPoniRot2_rad())
* result.geometry.GetDetectorDistance_mm() / result.geometry.GetPixelSize_mm();
result.converged = false;
result.reason = fmt::format(
"the tilt was declined at {:.1f}x its own sigma and pinned at the input file's "
"rot1 {:+.4f} deg, rot2 {:+.4f} deg - a tilt these rings measured no better than the "
"one they refused, and worth {:.1f} px of PONI at this distance",
result.tilt_significance,
header.GetPoniRot1_rad() * RAD_TO_DEG, header.GetPoniRot2_rad() * RAD_TO_DEG,
poni_pxl);
}
}
float TiltSignificance(const DiffractionGeometry &geom, const RingFitUncertainty &unc) {
if (!unc.valid)
return 0.0f;
@@ -328,6 +376,7 @@ CalibrationResult CalibrateFromProfile(const std::vector<float> &profile,
result.tilt_significance = significance;
result.seed_distance_mm = best_provenance.seed_mm;
result.header_distance_mm = geom.GetDetectorDistance_mm();
JudgeCalibration(result, geom, refine_tilt);
return result;
}
@@ -365,6 +414,7 @@ CalibrationResult CalibrateFromSpots(const std::vector<SpotToSave> &spots,
result.tilt_refined = tilt_refined;
result.tilt_significance = significance;
result.header_distance_mm = geom.GetDetectorDistance_mm();
JudgeCalibration(result, geom, refine_tilt);
return result;
}
@@ -462,6 +512,11 @@ void WriteCalibrationJson(const std::string &path, const DiffractionExperiment &
}
nlohmann::json calibration;
// The verdict first, because everything under it is only worth reading once it is known which of the
// two this file is. A caller that reads nothing else must still not mistake a non-fit for a fit.
calibration["converged"] = result.converged;
if (!result.converged)
calibration["not_converged_reason"] = result.reason;
calibration["calibrant"] = calibrant;
calibration["method"] = method;
calibration["ring_points"] = result.ring_points;
@@ -55,6 +55,14 @@ struct CalibrationResult {
// the residual says. Zero when no spots were available.
std::optional<DiffractionGeometry> spots_geometry;
float spots_disagreement_pxl = 0.0f;
// Whether the geometry above is a measurement of this data at all. A calibration is asked for
// precisely because the input file is not believed, so a fit that quietly hands part of that file
// back is not a weaker answer to the question - it is no answer, and it is indistinguishable from a
// good one in everything a caller reads. Two ways out of the fits below produce such a geometry, and
// both are stated rather than repaired here (see JudgeCalibration in the .cpp for why neither is a
// measurement). reason is empty exactly when converged is true, and is written for a person.
bool converged = true;
std::string reason;
};
// How many of its own sigmas the fitted tilt is away from zero - the number the gate above reads.
@@ -73,6 +81,13 @@ float TiltSignificance(const DiffractionGeometry &geom, const RingFitUncertainty
// positions fakes a tilt of the size usually seen while leaving sigma small.
constexpr float TILT_MIN_SIGNIFICANCE = 3.0f;
// Decide whether `result` is a measurement of the data and record the verdict on it: sets converged
// and, where that is false, the reason. header is the geometry the fit started from and refine_tilt
// whether a tilt was asked for at all - both fits below call this on the way out, and it is declared
// here so the rule can be tested without a powder pattern. See the definition for what makes a fit not
// a measurement, which is a question of provenance and not of any threshold.
void JudgeCalibration(CalibrationResult &result, const DiffractionGeometry &header, bool refine_tilt);
// Both fits take the detector tilt as a free parameter unless refine_tilt is false, which holds
// rot1/rot2 at the value `geom` came in with and fits only the beam centre and the distance. The
// tilt is real and a PONI carries it, but a program that has nowhere to put it - XDS - is better
+11 -3
View File
@@ -691,8 +691,15 @@ std::optional<CalibrationResult> Rugnux::RebinAndRefit(const CalibrationResult &
config_.calibration_refine_tilt, spots);
logger.Info("Powder calibration: that gives {} ring points at rms {:.3f} px",
refit.ring_points, refit.rms_radial_pxl);
if (!best || (refit.ring_points * 2 >= best->ring_points
&& refit.rms_radial_pxl < best->rms_radial_pxl))
// A refit that is not a measurement never beats one that is, whatever its residual. The
// residual of a fit with a parameter pinned at a value nobody measured is not comparable
// with the residual of one where every parameter was fitted - and this pass hands the
// NEXT pass the geometry it pins to, so taking a non-measurement here launders it into
// the following one's input.
if (!best || (refit.converged && !best->converged)
|| (refit.converged == best->converged
&& refit.ring_points * 2 >= best->ring_points
&& refit.rms_radial_pxl < best->rms_radial_pxl))
best = std::move(refit);
} catch (const std::exception &e) {
logger.Warning("Powder calibration: that profile could not be fitted ({})", e.what());
@@ -705,7 +712,8 @@ std::optional<CalibrationResult> Rugnux::RebinAndRefit(const CalibrationResult &
// Only if it is actually better. Re-binning about a geometry that was itself wrong makes the fit
// worse, not better, and the first pass is then the answer to keep - it is measured on a profile
// binned about a geometry now believed wrong, which is weak, but it is the one the run has.
if (best->ring_points * 2 < first.ring_points || best->rms_radial_pxl >= first.rms_radial_pxl) {
if ((first.converged && !best->converged)
|| best->ring_points * 2 < first.ring_points || best->rms_radial_pxl >= first.rms_radial_pxl) {
logger.Info("Powder calibration: the re-binned fit is no better than the first "
"({} points at rms {:.3f} against {} at {:.3f}) - keeping the first pass",
best->ring_points, best->rms_radial_pxl, first.ring_points, first.rms_radial_pxl);
+21 -1
View File
@@ -2170,10 +2170,22 @@ static int RunRugnux(int argc, char **argv) {
"from a shift of the beam centre at all" << std::endl;
}
// Whether any of the above is a measurement. A calibration that hands back a number out of the
// input file looks exactly like one that measured it - the same residual, the same sigmas, the
// same four decimal places - so it has to be said outright.
if (!cal.converged)
std::cout << "Verdict: NOT A CALIBRATION - " << cal.reason << std::endl;
const std::string poni_path = output_prefix + ".poni";
const std::string json_path = output_prefix + ".json";
try {
WritePoniFile(poni_path, experiment, g);
// A .poni file states where the detector is and nothing else. It has no field for doubt, so
// there is no honest way to write a geometry that is not a measurement into one - every
// program that reads it downstream would read it as if it were. Withholding it is the only
// thing the format allows. The JSON is written either way and carries the verdict, because
// it is where the evidence lives and it is wanted most exactly when the fit failed.
if (cal.converged)
WritePoniFile(poni_path, experiment, g);
// The same answer for whatever reads it next. A PONI file is what pyFAI and its neighbours
// take, but nothing in this system does, and it says nothing about how well the geometry is
// known - so write the JSON alongside it, with the geometry under dataset_settings' own
@@ -2184,6 +2196,14 @@ static int RunRugnux(int argc, char **argv) {
logger.Error("{}", e.what());
return 1;
}
if (!cal.converged) {
// Non-zero as well, because the caller this protects is a script, and a script reads the
// exit code and not the ring statistics.
logger.Error("Calibration did not converge: {}. Wrote {} with converged false; no {} was "
"written, because a PONI file cannot say that", cal.reason, json_path,
poni_path);
return 1;
}
logger.Info("Wrote {} and {}", poni_path, json_path);
return 0;
}
+89
View File
@@ -325,3 +325,92 @@ TEST_CASE("Calibration_JsonOmitsTheRotationsWhenTheyAreZero", "[DetGeomCalib]")
std::stringstream msg;
CHECK(model.validate(msg));
}
// The gate. A calibration is run because the input file's geometry is in doubt, so a fit that hands
// part of that file back has answered nothing - and it looks exactly like a fit that worked in every
// number a caller reads. These four cases are the whole rule: it turns on where a value came FROM, not
// on how large any residual is, so there is nothing here calibrated on a population.
TEST_CASE("Calibration_ADeclinedTiltPinnedAtTheFilesOwnIsNotAMeasurement", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).DetectorDistance_mm(190.0f);
// The tilt an in-house master file states, which is hardcoded and not measured on that instrument.
DiffractionGeometry header = x.GetDiffractionGeometry();
header.PoniRot1_rad(-0.0013963f).PoniRot2_rad(-0.0038397f);
CalibrationResult result;
result.geometry = header; // what the pinned refit hands back: the file's own tilt
result.uncertainty.valid = true;
result.tilt_refined = false;
result.tilt_significance = 2.93f;
JudgeCalibration(result, header, true);
CHECK_FALSE(result.converged);
CHECK_FALSE(result.reason.empty());
// ...and the same fit is honest where the file's tilt is zero, because then declining the tilt and
// reporting the file's value say the same thing. This is the long-distance regime the significance
// test was put in for, and it must keep passing.
DiffractionGeometry flat = x.GetDiffractionGeometry();
CalibrationResult declined_at_zero;
declined_at_zero.geometry = flat;
declined_at_zero.uncertainty.valid = true;
declined_at_zero.tilt_refined = false;
declined_at_zero.tilt_significance = 0.1f;
JudgeCalibration(declined_at_zero, flat, true);
CHECK(declined_at_zero.converged);
// ...and --no-refine-tilt over the same file is not this failure either: there the user asked for
// the file's tilt to be held, which is a stated choice and not a silent substitution.
CalibrationResult held;
held.geometry = header;
held.uncertainty.valid = true;
held.tilt_refined = false;
JudgeCalibration(held, header, false);
CHECK(held.converged);
// A covariance that never conditioned is the other way out with nothing measured behind it: the fit
// sits where some direction in parameter space costs it nothing, so it cannot say what it fixed.
CalibrationResult degenerate;
degenerate.geometry = x.GetDiffractionGeometry();
degenerate.uncertainty.valid = false;
degenerate.tilt_refined = true;
JudgeCalibration(degenerate, x.GetDiffractionGeometry(), true);
CHECK_FALSE(degenerate.converged);
}
// The verdict has to reach whatever reads the file next, not only the terminal - the .poni is withheld
// when it is false, so the JSON is the only place a caller can learn that a calibration ran and failed.
TEST_CASE("Calibration_JsonCarriesTheVerdict", "[DetGeomCalib]") {
DiffractionExperiment x(DetJF4M());
x.IncidentEnergy_keV(12.4f).DetectorDistance_mm(190.0f);
CalibrationResult result;
result.geometry = x.GetDiffractionGeometry();
result.converged = false;
result.reason = "the tilt was declined and pinned at the input file's value";
const std::string path = "calibration_json_verdict_test.json";
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in(path);
REQUIRE(in.good());
nlohmann::json j;
in >> j;
in.close();
std::remove(path.c_str());
CHECK(j.at("calibration").at("converged").get<bool>() == false);
CHECK(j.at("calibration").at("not_converged_reason").get<std::string>() == result.reason);
// A converged fit says so too, rather than saying nothing - absence would be read as an older file
// that could not have known, which is exactly the ambiguity this closes.
result.converged = true;
result.reason.clear();
WriteCalibrationJson(path, x, result, "lab6", "rings");
std::ifstream in2(path);
nlohmann::json j2;
in2 >> j2;
in2.close();
std::remove(path.c_str());
CHECK(j2.at("calibration").at("converged").get<bool>() == true);
CHECK_FALSE(j2.at("calibration").contains("not_converged_reason"));
}