diff --git a/image_analysis/scale_merge/CMakeLists.txt b/image_analysis/scale_merge/CMakeLists.txt index 62aefd28..b1ab0985 100644 --- a/image_analysis/scale_merge/CMakeLists.txt +++ b/image_analysis/scale_merge/CMakeLists.txt @@ -1,2 +1,4 @@ -ADD_LIBRARY(JFJochScaleMerge ScaleAndMerge.cpp ScaleAndMerge.h FrenchWilson.cpp FrenchWilson.h) +ADD_LIBRARY(JFJochScaleMerge ScaleAndMerge.cpp ScaleAndMerge.h FrenchWilson.cpp FrenchWilson.h + SearchSpaceGroup.cpp + SearchSpaceGroup.h) TARGET_LINK_LIBRARIES(JFJochScaleMerge Ceres::ceres Eigen3::Eigen JFJochCommon) \ No newline at end of file diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp new file mode 100644 index 00000000..70883a2c --- /dev/null +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -0,0 +1,426 @@ +#include "SearchSpaceGroup.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + struct MergedHKLKey { + int h = 0; + int k = 0; + int l = 0; + bool plus = true; + + bool operator==(const MergedHKLKey& o) const noexcept { + return h == o.h && k == o.k && l == o.l && plus == o.plus; + } + }; + + struct MergedHKLKeyHash { + size_t operator()(const MergedHKLKey& key) const noexcept { + auto mix = [](uint64_t x) { + x ^= x >> 33; + x *= 0xff51afd7ed558ccdULL; + x ^= x >> 33; + x *= 0xc4ceb9fe1a85ec53ULL; + x ^= x >> 33; + return x; + }; + return static_cast( + mix(static_cast(key.h)) ^ + (mix(static_cast(key.k)) << 1) ^ + (mix(static_cast(key.l)) << 2) ^ + (mix(static_cast(key.plus ? 1 : 0)) << 3)); + } + }; + + bool IsIdentityOp(const gemmi::Op& op) { + return op.rot == gemmi::Op::identity().rot; + } + + bool IsParityChanging(const gemmi::Op& op) { + return op.det_rot() < 0; + } + + MergedHKLKey CanonicalizeMergedHKL(int h, int k, int l, bool merge_friedel) { + MergedHKLKey key{h, k, l, true}; + if (merge_friedel) + return key; + + const std::tuple pos{h, k, l}; + const std::tuple neg{-h, -k, -l}; + if (neg < pos) { + key.h = -h; + key.k = -k; + key.l = -l; + key.plus = false; + } + return key; + } + + bool ReflectionPassesFilters(const MergedReflection& r, const SearchSpaceGroupOptions& opt) { + if (!std::isfinite(r.I)) + return false; + if (!std::isfinite(r.sigma) || r.sigma <= 0.0) + return false; + if (!std::isfinite(r.d) || r.d <= 0.0) + return false; + if (opt.d_min_limit_A > 0.0 && r.d < opt.d_min_limit_A) + return false; + if (opt.min_i_over_sigma > 0.0 && r.I / r.sigma < opt.min_i_over_sigma) + return false; + return true; + } + + std::unordered_map + BuildMergedLookup(const std::vector& merged, const SearchSpaceGroupOptions& opt) { + std::unordered_map out; + out.reserve(merged.size()); + + for (const auto& r : merged) { + if (!ReflectionPassesFilters(r, opt)) + continue; + const auto key = CanonicalizeMergedHKL(r.h, r.k, r.l, opt.merge_friedel); + out.emplace(key, &r); + } + + return out; + } + + double PearsonCC(const std::vector& x, const std::vector& y) { + if (x.size() != y.size() || x.size() < 2) + return std::numeric_limits::quiet_NaN(); + + double sx = 0.0, sy = 0.0; + double sxx = 0.0, syy = 0.0, sxy = 0.0; + + for (size_t i = 0; i < x.size(); ++i) { + sx += x[i]; + sy += y[i]; + sxx += x[i] * x[i]; + syy += y[i] * y[i]; + sxy += x[i] * y[i]; + } + + const double n = static_cast(x.size()); + const double cov = sxy - sx * sy / n; + const double vx = sxx - sx * sx / n; + const double vy = syy - sy * sy / n; + + if (vx <= 0.0 || vy <= 0.0) + return std::numeric_limits::quiet_NaN(); + + return cov / std::sqrt(vx * vy); + } + + std::optional ScoreOperator( + const gemmi::Op& op, + const std::vector& merged, + const std::unordered_map& lookup, + const SearchSpaceGroupOptions& opt) { + + std::vector x; + std::vector y; + x.reserve(merged.size() / 2); + y.reserve(merged.size() / 2); + + std::unordered_set used; + used.reserve(merged.size() / 2); + + for (const auto& r : merged) { + if (!ReflectionPassesFilters(r, opt)) + continue; + + const auto key1 = CanonicalizeMergedHKL(r.h, r.k, r.l, opt.merge_friedel); + if (used.find(key1) != used.end()) + continue; + + const gemmi::Op::Miller hkl{{r.h, r.k, r.l}}; + const auto hkl2 = op.apply_to_hkl(hkl); + const auto key2 = CanonicalizeMergedHKL(hkl2[0], hkl2[1], hkl2[2], opt.merge_friedel); + + if (key1.h == key2.h && key1.k == key2.k && key1.l == key2.l && key1.plus == key2.plus) + continue; + + auto it = lookup.find(key2); + if (it == lookup.end()) + continue; + + const auto* mate = it->second; + if (mate == nullptr) + continue; + + x.push_back(r.I); + y.push_back(mate->I); + + used.insert(key1); + used.insert(key2); + } + + if (x.empty()) + return std::nullopt; + + SpaceGroupOperatorScore out; + out.op_triplet_hkl = op.as_hkl().triplet('h'); + out.compared = static_cast(x.size()); + out.cc = PearsonCC(x, y); + out.accepted = (out.compared >= opt.min_pairs_per_operator && + std::isfinite(out.cc) && + out.cc >= opt.min_operator_cc); + return out; + } + + SpaceGroupAbsenceScore ScoreSystematicAbsences( + const gemmi::GroupOps& gops, + const std::vector& merged, + const SearchSpaceGroupOptions& opt) { + + SpaceGroupAbsenceScore out; + + if (!opt.test_systematic_absences) + return out; + + double sum_i_over_sigma = 0.0; + + for (const auto& r : merged) { + if (!ReflectionPassesFilters(r, opt)) + continue; + + const gemmi::Op::Miller hkl{{r.h, r.k, r.l}}; + if (!gops.is_systematically_absent(hkl)) + continue; + + out.violating_reflections += 1; + sum_i_over_sigma += std::max(0.0, r.I / r.sigma); + } + + if (out.violating_reflections > 0) + out.mean_i_over_sigma = sum_i_over_sigma / out.violating_reflections; + else + out.mean_i_over_sigma = 0.0; + + out.accepted = (out.violating_reflections <= opt.max_absent_violations) && + (out.mean_i_over_sigma <= opt.max_mean_absent_i_over_sigma); + return out; + } + + bool IsCenteringCompatible(char requested, char candidate) { + if (requested == '\0') + return true; + return std::toupper(static_cast(requested)) == + std::toupper(static_cast(candidate)); + } + + bool IsCandidateSpaceGroup(const gemmi::SpaceGroup& sg, + const std::optional& crystal_system, + char centering) { + if (!sg.is_reference_setting()) + return false; + if (!sg.is_sohncke()) + return false; + if (crystal_system.has_value() && sg.crystal_system() != crystal_system.value()) + return false; + if (!IsCenteringCompatible(centering, sg.centring_type())) + return false; + return true; + } + + std::vector EnumerateCandidateSpaceGroups( + const std::optional& crystal_system, + char centering) { + + std::vector out; + + for (const auto& sg : gemmi::spacegroup_tables::main) { + if (!IsCandidateSpaceGroup(sg, crystal_system, centering)) + continue; + out.push_back(sg); + } + + std::sort(out.begin(), out.end(), + [](const gemmi::SpaceGroup& a, const gemmi::SpaceGroup& b) { + const int order_a = a.operations().derive_symmorphic().order(); + const int order_b = b.operations().derive_symmorphic().order(); + if (order_a != order_b) + return order_a < order_b; + return a.number < b.number; + }); + return out; + } +} + +SearchSpaceGroupResult SearchSpaceGroup( + const std::vector& merged, + const SearchSpaceGroupOptions& opt) { + + SearchSpaceGroupResult result; + if (merged.empty()) + return result; + + const auto lookup = BuildMergedLookup(merged, opt); + const auto candidates = EnumerateCandidateSpaceGroups(opt.crystal_system, opt.centering); + + for (const auto& sg : candidates) { + SpaceGroupCandidateScore score{.space_group = sg}; + + const gemmi::GroupOps gops_full = sg.operations(); + const gemmi::GroupOps gops_rot = gops_full.derive_symmorphic(); + + double cc_sum = 0.0; + int cc_count = 0; + int compared_total = 0; + double min_cc = std::numeric_limits::infinity(); + + for (const auto& op : gops_rot.sym_ops) { + if (IsIdentityOp(op)) + continue; + if (IsParityChanging(op)) + continue; + + auto op_score = ScoreOperator(op, merged, lookup, opt); + if (!op_score.has_value()) + continue; + + compared_total += op_score->compared; + score.operator_scores.push_back(*op_score); + + if (op_score->compared >= opt.min_pairs_per_operator && std::isfinite(op_score->cc)) { + cc_sum += op_score->cc; + min_cc = std::min(min_cc, op_score->cc); + cc_count += 1; + if (op_score->accepted) + score.accepted_operators += 1; + } + } + + score.absence_score = ScoreSystematicAbsences(gops_full, merged, opt); + score.tested_operators = static_cast(score.operator_scores.size()); + score.compared_total = compared_total; + score.mean_cc = (cc_count > 0) ? (cc_sum / cc_count) : 0.0; + score.min_cc = std::isfinite(min_cc) ? min_cc : 0.0; + + const bool trivial_group = (gops_rot.order() <= 1); + const bool rotationally_accepted = + trivial_group || + ((score.tested_operators > 0) && + (score.accepted_operators == score.tested_operators) && + (score.compared_total >= opt.min_total_compared)); + + score.accepted = rotationally_accepted && score.absence_score.accepted; + + result.candidates.push_back(std::move(score)); + } + + std::sort(result.candidates.begin(), result.candidates.end(), + [](const SpaceGroupCandidateScore& a, const SpaceGroupCandidateScore& b) { + if (a.accepted != b.accepted) + return a.accepted > b.accepted; + + if (a.absence_score.mean_i_over_sigma != b.absence_score.mean_i_over_sigma) + return a.absence_score.mean_i_over_sigma < b.absence_score.mean_i_over_sigma; + + if (a.absence_score.violating_reflections != b.absence_score.violating_reflections) + return a.absence_score.violating_reflections < b.absence_score.violating_reflections; + + const int order_a = a.space_group.operations().derive_symmorphic().order(); + const int order_b = b.space_group.operations().derive_symmorphic().order(); + if (order_a != order_b) + return order_a > order_b; + + if (a.accepted_operators != b.accepted_operators) + return a.accepted_operators > b.accepted_operators; + if (a.min_cc != b.min_cc) + return a.min_cc > b.min_cc; + if (a.mean_cc != b.mean_cc) + return a.mean_cc > b.mean_cc; + + return a.space_group.number < b.space_group.number; + }); + + for (const auto& cand : result.candidates) { + if (cand.accepted) { + result.best_space_group = cand.space_group; + break; + } + } + + return result; +} + +std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result, + size_t max_candidates_to_print) { + std::ostringstream os; + + os << "Space-group candidates\n"; + os << " " + << std::setw(10) << "SG" + << " " + << std::setw(4) << "Acc" + << " " + << std::setw(8) << "" + << " " + << std::setw(8) << "minCC" + << " " + << std::setw(9) << "compared" + << " " + << std::setw(7) << "ops" + << " " + << std::setw(8) << "absent" + << " " + << std::setw(10) << "abs" + << "\n"; + + os << " " + << std::setw(10) << "----------" + << " " + << std::setw(4) << "----" + << " " + << std::setw(8) << "--------" + << " " + << std::setw(8) << "--------" + << " " + << std::setw(9) << "---------" + << " " + << std::setw(7) << "-------" + << " " + << std::setw(8) << "--------" + << " " + << std::setw(10) << "----------" + << "\n"; + + const size_t n = std::min(max_candidates_to_print, result.candidates.size()); + for (size_t i = 0; i < n; ++i) { + const auto& c = result.candidates[i]; + os << " " + << std::setw(10) << c.space_group.short_name() + << " " + << std::setw(4) << (c.accepted ? "yes" : "no") + << " " + << std::setw(8) << std::fixed << std::setprecision(3) << c.mean_cc + << " " + << std::setw(8) << std::fixed << std::setprecision(3) << c.min_cc + << " " + << std::setw(9) << c.compared_total + << " " + << std::setw(3) << c.accepted_operators << "/" << std::setw(3) << c.tested_operators + << " " + << std::setw(8) << c.absence_score.violating_reflections + << " " + << std::setw(10) << std::fixed << std::setprecision(2) << c.absence_score.mean_i_over_sigma + << "\n"; + } + + if (result.best_space_group.has_value()) + os << "Best space group: " << result.best_space_group->short_name() << "\n"; + else + os << "Best space group: none accepted\n"; + + return os.str(); +} \ No newline at end of file diff --git a/image_analysis/scale_merge/SearchSpaceGroup.h b/image_analysis/scale_merge/SearchSpaceGroup.h new file mode 100644 index 00000000..b044869c --- /dev/null +++ b/image_analysis/scale_merge/SearchSpaceGroup.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include "ScaleAndMerge.h" +#include "gemmi/symmetry.hpp" + +struct SpaceGroupOperatorScore { + std::string op_triplet_hkl; + double cc = 0.0; + int compared = 0; + bool accepted = false; +}; + +struct SpaceGroupAbsenceScore { + int violating_reflections = 0; + double mean_i_over_sigma = 0.0; + bool accepted = true; +}; + +struct SpaceGroupCandidateScore { + gemmi::SpaceGroup space_group; + double mean_cc = 0.0; + double min_cc = 0.0; + int compared_total = 0; + int accepted_operators = 0; + int tested_operators = 0; + bool accepted = false; + SpaceGroupAbsenceScore absence_score; + std::vector operator_scores; +}; + +struct SearchSpaceGroupOptions { + // If not set, search all crystal systems. + std::optional crystal_system; + + // '\0' means: search all centerings compatible with the candidate system. + char centering = '\0'; + + // If true, Friedel mates are treated as equivalent when matching HKLs. + bool merge_friedel = true; + + // Ignore reflections beyond this resolution (higher-resolution means smaller d). + // Set to 0 to disable the cutoff. + double d_min_limit_A = 0.0; + + // Optional I/sigma filter on merged reflections. + double min_i_over_sigma = 0.0; + + // Acceptance thresholds for each tested rotational operator. + double min_operator_cc = 0.80; + int min_pairs_per_operator = 20; + + // Minimum total number of compared reflection pairs for a candidate SG. + int min_total_compared = 100; + + // Test systematic absences from centering / screws / glides. + bool test_systematic_absences = true; + + // Candidate is rejected if forbidden reflections are too strong on average. + double max_mean_absent_i_over_sigma = 1.0; + + // Hard cap on number of observed forbidden reflections. + int max_absent_violations = 0; +}; + +struct SearchSpaceGroupResult { + std::optional best_space_group; + std::vector candidates; +}; + +SearchSpaceGroupResult SearchSpaceGroup( + const std::vector& merged, + const SearchSpaceGroupOptions& opt = {}); + +std::string SearchSpaceGroupResultToText( + const SearchSpaceGroupResult& result, + size_t max_candidates_to_print = 20); \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e4800f6a..1c4bb8f4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -66,6 +66,7 @@ ADD_EXECUTABLE(jfjoch_test HKLKeyTest.cpp TCPImagePusherTest.cpp BraggIntegrate2DTest.cpp + SearchSpaceGroupTest.cpp ) target_link_libraries(jfjoch_test Catch2WithMain JFJochBroker JFJochReceiver JFJochReader JFJochWriter JFJochImageAnalysis JFJochCommon JFJochHLSSimulation JFJochPreview) diff --git a/tests/SearchSpaceGroupTest.cpp b/tests/SearchSpaceGroupTest.cpp new file mode 100644 index 00000000..8e7290bd --- /dev/null +++ b/tests/SearchSpaceGroupTest.cpp @@ -0,0 +1,143 @@ +#include + +#include "../image_analysis/scale_merge/SearchSpaceGroup.h" +#include "gemmi/symmetry.hpp" + +#include +#include +#include +#include +#include +#include + +namespace { + struct HKL { + int h = 0; + int k = 0; + int l = 0; + + bool operator==(const HKL& o) const noexcept { + return h == o.h && k == o.k && l == o.l; + } + }; + + struct HKLHash { + size_t operator()(const HKL& x) const noexcept { + auto mix = [](uint64_t v) { + v ^= v >> 33; + v *= 0xff51afd7ed558ccdULL; + v ^= v >> 33; + v *= 0xc4ceb9fe1a85ec53ULL; + v ^= v >> 33; + return v; + }; + return static_cast( + mix(static_cast(x.h)) ^ + (mix(static_cast(x.k)) << 1) ^ + (mix(static_cast(x.l)) << 2)); + } + }; + + double CalcSyntheticD(int h, int k, int l) { + const double q2 = static_cast(h * h + k * k + l * l); + return 40.0 / std::sqrt(q2 + 1.0); + } + + double SyntheticIntensityFromAsu(const gemmi::Op::Miller& asu) { + uint64_t x = static_cast((asu[0] + 31) * 73856093u) ^ + static_cast((asu[1] + 37) * 19349663u) ^ + static_cast((asu[2] + 41) * 83492791u); + x ^= x >> 13; + x *= 0x9e3779b97f4a7c15ULL; + x ^= x >> 17; + return 100.0 + static_cast(x % 500); + } + + std::vector GenerateMergedReflectionsForSpaceGroup( + const gemmi::SpaceGroup& sg, + int hmax = 8) { + + std::vector merged; + std::unordered_set added; + + const gemmi::GroupOps gops = sg.operations(); + const gemmi::ReciprocalAsu rasu(&sg); + + for (int h = -hmax; h <= hmax; ++h) { + for (int k = -hmax; k <= hmax; ++k) { + for (int l = -hmax; l <= hmax; ++l) { + if (h == 0 && k == 0 && l == 0) + continue; + + gemmi::Op::Miller hkl{{h, k, l}}; + if (gops.is_systematically_absent(hkl)) + continue; + + const auto [asu, sign_plus] = rasu.to_asu_sign(hkl, gops); + if (!sign_plus) + continue; + + const HKL key{h, k, l}; + if (added.find(key) != added.end()) + continue; + added.insert(key); + + merged.push_back(MergedReflection{ + .h = h, + .k = k, + .l = l, + .I = SyntheticIntensityFromAsu(asu), + .sigma = 1.0, + .d = CalcSyntheticD(h, k, l) + }); + } + } + } + + return merged; + } +} + +TEST_CASE("SearchSpaceGroup detects synthetic space groups") { + struct Case { + std::string input_name; + std::string expected_short_name; + }; + + const std::vector cases = { + {"P 1", "P1"}, + {"P 1 2 1", "P2"}, + {"P 3 2 1", "P321"}, + {"P 4 2 2", "P422"}, + {"P 4 3 2", "P432"}, + {"P 6 2 2", "P622"}, + {"C 1 2 1", "C2"}, + {"C 2 2 2", "C222"}, + {"I 4 3 2", "I432"}, + }; + + for (const auto& tc : cases) { + DYNAMIC_SECTION(tc.expected_short_name) { + const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name(tc.input_name); + const auto merged = GenerateMergedReflectionsForSpaceGroup(sg); + + SearchSpaceGroupOptions opt; + opt.crystal_system.reset(); + opt.centering = '\0'; + opt.merge_friedel = true; + opt.d_min_limit_A = 2.0; + opt.min_operator_cc = 0.99; + opt.min_pairs_per_operator = 20; + opt.min_total_compared = 80; + opt.test_systematic_absences = true; + opt.max_mean_absent_i_over_sigma = 0.1; + opt.max_absent_violations = 0; + + const auto result = SearchSpaceGroup(merged, opt); + + INFO(SearchSpaceGroupResultToText(result)); + REQUIRE(result.best_space_group.has_value()); + CHECK(result.best_space_group->short_name() == tc.expected_short_name); + } + } +} \ No newline at end of file diff --git a/tools/jfjoch_process.cpp b/tools/jfjoch_process.cpp index 102a9d7e..7aef052d 100644 --- a/tools/jfjoch_process.cpp +++ b/tools/jfjoch_process.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include "../reader/JFJochHDF5Reader.h" #include "../common/Logger.h" @@ -25,7 +26,8 @@ #include "../receiver/JFJochReceiverPlots.h" #include "../compression/JFJochCompressor.h" #include "../image_analysis/scale_merge/FrenchWilson.h" -#include "../image_analysis/WriteMmcif.h" // (actually include at top of file) +#include "../image_analysis/scale_merge/SearchSpaceGroup.h" +#include "../image_analysis/WriteMmcif.h" void print_usage(Logger &logger) { logger.Info("Usage ./jfjoch_analysis {} "); @@ -517,6 +519,7 @@ int main(int argc, char **argv) { logger.Info("Rotation Indexing found lattice"); } + // --- Optional: run scaling (mosaicity refinement) on accumulated reflections --- // --- Optional: run scaling (mosaicity refinement) on accumulated reflections --- if (run_scaling) { logger.Info("Running scaling (mosaicity refinement) ..."); @@ -528,6 +531,8 @@ int main(int argc, char **argv) { scale_opts.merge_friedel = !anomalous_mode; scale_opts.d_min_limit_A = d_min_scale_merge.value_or(0.0); + const bool fixed_space_group = space_group || experiment.GetGemmiSpaceGroup().has_value(); + if (space_group) scale_opts.space_group = *space_group; else @@ -538,46 +543,91 @@ int main(int argc, char **argv) { auto scale_end = std::chrono::steady_clock::now(); double scale_time = std::chrono::duration(scale_end - scale_start).count(); + if (scale_result && !fixed_space_group) { + logger.Info("Searching for space group from P1-merged reflections ..."); + + SearchSpaceGroupOptions sg_opts; + sg_opts.crystal_system.reset(); + sg_opts.centering = '\0'; + sg_opts.merge_friedel = !anomalous_mode; + sg_opts.d_min_limit_A = d_min_scale_merge.value_or(0.0); + sg_opts.min_i_over_sigma = 0.0; + sg_opts.min_operator_cc = 0.80; + sg_opts.min_pairs_per_operator = 20; + sg_opts.min_total_compared = 100; + sg_opts.test_systematic_absences = true; + sg_opts.max_mean_absent_i_over_sigma = 1.5; + sg_opts.max_absent_violations = 3; + + const auto sg_search = SearchSpaceGroup(scale_result->merged, sg_opts); + + logger.Info(""); + { + std::istringstream iss(SearchSpaceGroupResultToText(sg_search)); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty()) + logger.Info("{}", line); + } + } + logger.Info(""); + + if (sg_search.best_space_group.has_value()) { + logger.Info("Re-running scaling in detected space group {}", sg_search.best_space_group->short_name()); + + scale_opts.space_group = *sg_search.best_space_group; + + auto rescale_start = std::chrono::steady_clock::now(); + auto refined_scale_result = indexer.ScaleRotationData(scale_opts); + auto rescale_end = std::chrono::steady_clock::now(); + + if (refined_scale_result) { + scale_result = std::move(refined_scale_result); + scale_time += std::chrono::duration(rescale_end - rescale_start).count(); + } + } else { + logger.Warning("No space group accepted; keeping P1-merged result"); + } + } + if (scale_result) { end_msg.scale_factor = scale_result->image_scale_g; - logger.Info("Scaling completed in {:.2f} s ({} unique reflections)", - scale_time, scale_result->merged.size()); + logger.Info("Scaling completed in {:.2f} s ({} unique reflections)", + scale_time, scale_result->merged.size()); - // Print resolution-shell statistics table + // Print resolution-shell statistics table + { + const auto &stats = scale_result->statistics; + logger.Info(""); + logger.Info(" {:>8s} {:>8s} {:>8s} {:>8s} {:>8s} {:>10s}", + "d_min", "N_obs", "N_uniq", "Rmeas", "", "Complete"); + logger.Info(" {:->8s} {:->8s} {:->8s} {:->8s} {:->8s} {:->10s}", + "", "", "", "", "", ""); + for (const auto &sh: stats.shells) { + if (sh.unique_reflections == 0) + continue; + std::string compl_str = (sh.completeness > 0.0) + ? fmt::format("{:8.1f}%", sh.completeness * 100.0) + : " N/A"; + logger.Info(" {:8.2f} {:8d} {:8d} {:8.3f}% {:8.1f} {:>10s}", + sh.d_min, sh.total_observations, sh.unique_reflections, + sh.rmeas * 100, sh.mean_i_over_sigma, compl_str); + } { - const auto& stats = scale_result->statistics; - logger.Info(""); - logger.Info(" {:>8s} {:>8s} {:>8s} {:>8s} {:>8s} {:>10s}", - "d_min", "N_obs", "N_uniq", "Rmeas", "", "Complete"); + const auto &ov = stats.overall; logger.Info(" {:->8s} {:->8s} {:->8s} {:->8s} {:->8s} {:->10s}", "", "", "", "", "", ""); - for (const auto& sh : stats.shells) { - if (sh.unique_reflections == 0) - continue; - std::string compl_str = (sh.completeness > 0.0) - ? fmt::format("{:8.1f}%", sh.completeness * 100.0) - : " N/A"; - logger.Info(" {:8.2f} {:8d} {:8d} {:8.3f}% {:8.1f} {:>10s}", - sh.d_min, sh.total_observations, sh.unique_reflections, - sh.rmeas * 100, sh.mean_i_over_sigma, compl_str); - } - // Overall - { - const auto& ov = stats.overall; - logger.Info(" {:->8s} {:->8s} {:->8s} {:->8s} {:->8s} {:->10s}", - "", "", "", "", "", ""); - std::string compl_str = (ov.completeness > 0.0) - ? fmt::format("{:8.1f}%", ov.completeness * 100.0) - : " N/A"; - logger.Info(" {:>8s} {:8d} {:8d} {:8.3f}% {:8.1f} {:>10s}", - "Overall", ov.total_observations, ov.unique_reflections, - ov.rmeas * 100, ov.mean_i_over_sigma, compl_str); - } - logger.Info(""); + std::string compl_str = (ov.completeness > 0.0) + ? fmt::format("{:8.1f}%", ov.completeness * 100.0) + : " N/A"; + logger.Info(" {:>8s} {:8d} {:8d} {:8.3f}% {:8.1f} {:>10s}", + "Overall", ov.total_observations, ov.unique_reflections, + ov.rmeas * 100, ov.mean_i_over_sigma, compl_str); } + logger.Info(""); + } - // Write image.dat (image_id mosaicity_deg K) { const std::string img_path = output_prefix + "_image.dat"; std::ofstream img_file(img_path); @@ -586,13 +636,13 @@ int main(int argc, char **argv) { } else { img_file << "# image_id mosaicity_deg K\n"; for (size_t i = 0; i < scale_result->mosaicity_deg.size(); ++i) { - img_file << i << " " << scale_result->mosaicity_deg[i] << " " << scale_result->image_scale_g[i] << "\n"; + img_file << i << " " << scale_result->mosaicity_deg[i] << " " << scale_result->image_scale_g[i] + << "\n"; } img_file.close(); } } - // --- French-Wilson: convert I → F --- { FrenchWilsonOptions fw_opts; fw_opts.acentric = true; // typical for MX @@ -601,7 +651,6 @@ int main(int argc, char **argv) { auto fw = FrenchWilson(scale_result->merged, fw_opts); { { - // Write scaled.hkl (h k l I sigma) const std::string hkl_path = output_prefix + "_amplitudes.hkl"; std::ofstream hkl_file(hkl_path); if (!hkl_file) { @@ -619,23 +668,20 @@ int main(int argc, char **argv) { } MmcifMetadata cif_meta; - // Unit cell — from rotation indexing result or experiment setting if (rotation_indexer_ret.has_value()) { cif_meta.unit_cell = rotation_indexer_ret->lattice.GetUnitCell(); } else if (experiment.GetUnitCell().has_value()) { cif_meta.unit_cell = experiment.GetUnitCell().value(); } - // Space group - if (auto sg = experiment.GetGemmiSpaceGroup(); sg.has_value()) { + if (scale_opts.space_group.has_value()) { + cif_meta.space_group_name = scale_opts.space_group->hm; + cif_meta.space_group_number = scale_opts.space_group->number; + } else if (auto sg = experiment.GetGemmiSpaceGroup(); sg.has_value()) { cif_meta.space_group_name = sg->hm; cif_meta.space_group_number = sg->number; - } else if (space_group) { - cif_meta.space_group_name = space_group->hm; - cif_meta.space_group_number = space_group->number; } - // Detector & experiment info cif_meta.detector_name = experiment.GetDetectorDescription(); cif_meta.wavelength_A = experiment.GetWavelength_A(); cif_meta.detector_distance_mm = experiment.GetDetectorDistance_mm();