diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f9fb40..aa037b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -91,6 +91,7 @@ endif() set(SONATA_SRC src/common.cpp + src/compartment_sets.cpp src/config.cpp src/edge_index.cpp src/edges.cpp diff --git a/include/bbp/sonata/compartment_sets.h b/include/bbp/sonata/compartment_sets.h new file mode 100644 index 0000000..802aab5 --- /dev/null +++ b/include/bbp/sonata/compartment_sets.h @@ -0,0 +1,189 @@ +#pragma once + +#include + +#include + +namespace bbp { +namespace sonata { +namespace detail { +class CompartmentSetFilteredIterator; +class CompartmentSet; +class CompartmentSets; +} // namespace detail + +/** + * CompartmentLocation. + * + * This struct uniquely identifies a compartment by a set of node_id, section_index and offset: + * + * - node_id: Global ID of the cell (Neuron) to which the compartment belongs. No + * overlaps among populations. + * - section_index: Absolute section index. Progressive index that uniquely identifies the section. + * There is a mapping between neuron section names (i.e. dend[10]) and this index. + * - offset: Offset of the compartment along the section. The offset is a value between 0 and 1 + * + * Note: it cannot go inside CompartmentSet because then CompartmentSetFilteredIterator needs the + * full definition of CompartmentSet and CompartmentSet needs the full definition of + * CompartmentSetFilteredIterator. + */ +struct CompartmentLocation { + public: + uint64_t nodeId = 0; + uint64_t sectionIndex = 0; + double offset = 0.0; + + /// Comparator. Used to compare vectors in CompartmentSet. More idiomatic than defining a + /// comaprator on the fly + bool operator==(const CompartmentLocation& other) const { + return nodeId == other.nodeId && sectionIndex == other.sectionIndex && + offset == other.offset; + } + + bool operator!=(const CompartmentLocation& other) const { + return !(*this == other); + } +}; + +/// Ostream << operator used by catch2 when there are problems for example +inline std::ostream& operator<<(std::ostream& os, const CompartmentLocation& cl) { + os << "CompartmentLocation(" + << "nodeId: " << cl.nodeId << ", " + << "sectionIndex: " << cl.sectionIndex << ", " + << "offset: " << cl.offset << ")"; + return os; +} + +class SONATA_API CompartmentSetFilteredIterator { +public: + using iterator_category = std::forward_iterator_tag; + using value_type = CompartmentLocation; + using difference_type = std::ptrdiff_t; + using pointer = const CompartmentLocation*; + using reference = const CompartmentLocation&; + + explicit CompartmentSetFilteredIterator( + std::unique_ptr impl); + CompartmentSetFilteredIterator(const CompartmentSetFilteredIterator& other); + CompartmentSetFilteredIterator& operator=(const CompartmentSetFilteredIterator& other); + CompartmentSetFilteredIterator(CompartmentSetFilteredIterator&&) noexcept; + CompartmentSetFilteredIterator& operator=(CompartmentSetFilteredIterator&&) noexcept; + ~CompartmentSetFilteredIterator(); + + const CompartmentLocation& operator*() const; + const CompartmentLocation* operator->() const; + + CompartmentSetFilteredIterator& operator++(); // prefix ++ + CompartmentSetFilteredIterator operator++(int); // postfix ++ + bool operator==(const CompartmentSetFilteredIterator& other) const; + bool operator!=(const CompartmentSetFilteredIterator& other) const; + +private: + std::unique_ptr impl_; +}; + + +/** + * CompartmentSet public API. + * + * This class represents a set of compartment locations associated with a neuron population. + * Each compartment is uniquely defined by a (node_id, section_index, offset) triplet. + * This API supports filtering based on a node_id selection. + */ +class SONATA_API CompartmentSet +{ + public: + CompartmentSet() = delete; + + explicit CompartmentSet(const std::string& json_content); + explicit CompartmentSet(std::shared_ptr&& impl); + + std::pair filtered_crange( + Selection selection = Selection({})) const; + + /// Size of the set, optionally filtered by selection + std::size_t size(const Selection& selection = Selection({})) const; + + // Is empty? + bool empty() const; + + /// Population name + const std::string& population() const; + + /// Access element by index. It returns a copy! + CompartmentLocation operator[](std::size_t index) const; + + Selection nodeIds() const; + + CompartmentSet filter(const Selection& selection = Selection({})) const; + + /// Serialize to JSON string + std::string toJSON() const; + + bool operator==(const CompartmentSet& other) const; + bool operator!=(const CompartmentSet& other) const; + + private: + std::shared_ptr impl_; +}; + + +/** + * @class CompartmentSets + * @brief A container class that manages a collection of named CompartmentSet objects. + * + * This class provides methods for accessing, querying, and serializing a collection of + * compartment sets identified by string keys. It supports construction from a JSON string + * or a file, and encapsulates its internal implementation using the PIMPL idiom. + * + * The class is non-copyable but movable, and offers value-style accessors for ease of use. + */ +class SONATA_API CompartmentSets +{ +public: + + CompartmentSets(const std::string& content); + CompartmentSets(std::unique_ptr&& impl); + CompartmentSets(detail::CompartmentSets&& impl); + CompartmentSets(CompartmentSets&&) noexcept; + CompartmentSets(const CompartmentSets& other) = delete; + CompartmentSets& operator=(CompartmentSets&&) noexcept; + ~CompartmentSets(); + + /// Create new CompartmentSets from file. In this way we distinguish from + /// the basic string constructor. + static CompartmentSets fromFile(const std::string& path); + + /// Access element by key (throws if not found) + CompartmentSet getCompartmentSet(const std::string& key) const; + + /// Number of compartment sets + std::size_t size() const; + + /// Is empty? + bool empty() const; + + /// Check if key exists + bool contains(const std::string& key) const; + + /// Get names of CompartmentSet(s) as a vector + std::vector names() const; + + /// Get all compartment sets as vector + std::vector getAllCompartmentSets() const; + + /// Get items (key + compartment set) as vector of pairs + std::vector> items() const; + + /// Serialize all compartment sets to JSON string + std::string toJSON() const; + + bool operator==(const CompartmentSets& other) const; + bool operator!=(const CompartmentSets& other) const; + +private: + std::unique_ptr impl_; +}; + +} // namespace sonata +} // namespace bbp diff --git a/include/bbp/sonata/config.h b/include/bbp/sonata/config.h index 0cb5502..25c49e3 100644 --- a/include/bbp/sonata/config.h +++ b/include/bbp/sonata/config.h @@ -170,6 +170,11 @@ class SONATA_API CircuitConfig */ const std::string& getNodeSetsPath() const; + /** + * Returns the path to the compartment sets file. + */ + const std::string& getCompartmentSetsPath() const; + /** * Returns a set with all available population names across all the node networks. */ @@ -782,6 +787,11 @@ class SONATA_API SimulationConfig */ const std::string& getNodeSetsFile() const noexcept; + /** + * Returns the path of compartment sets file + */ + const std::string& getCompartmentSetsFile() const noexcept; + /** * Returns the name of node set to be instantiated for the simulation, default = None */ @@ -828,6 +838,8 @@ class SONATA_API SimulationConfig SimulatorType _targetSimulator; // Path of node sets file std::string _nodeSetsFile; + // Path of compartment sets file + std::string _compartmentSetsFile; // Name of node set nonstd::optional _nodeSet{nonstd::nullopt}; // Remarks on the simulation diff --git a/include/bbp/sonata/selection.h b/include/bbp/sonata/selection.h index cca98f7..f0432db 100644 --- a/include/bbp/sonata/selection.h +++ b/include/bbp/sonata/selection.h @@ -17,6 +17,10 @@ class SONATA_API Selection using Range = std::array; using Ranges = std::vector; + /** + * Create Selection from a list of ranges + * @param ranges is a list of ranges constituting Selection + */ Selection(Ranges ranges); template @@ -40,6 +44,13 @@ class SONATA_API Selection bool empty() const; + /** + * Check if Selection contains a given node id + * @param node id to check + * @return true if Selection contains the node id, false otherwise + */ + bool contains(Value node_id) const; + private: Ranges ranges_; }; diff --git a/pyproject.toml b/pyproject.toml index 457b232..401474f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,3 +9,7 @@ build-backend = "setuptools.build_meta" [tool.pytest.ini_options] testpaths = ["python/tests"] + +[tool.setuptools_scm] +version_scheme = "guess-next-dev" +local_scheme = "no-local-version" diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 70bde60..578c788 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -17,4 +17,5 @@ target_link_libraries(sonata_python PRIVATE HighFive PRIVATE fmt::fmt-header-only PRIVATE pybind11::module + PRIVATE nlohmann_json::nlohmann_json ) diff --git a/python/bindings.cpp b/python/bindings.cpp index 9a7f632..1bc00c1 100644 --- a/python/bindings.cpp +++ b/python/bindings.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include //nonstd::optional #include @@ -126,6 +127,9 @@ py::object getDynamicsAttributeVectorWithDefault(const Population& obj, // create a macro to reduce repetition for docstrings #define DOC_NODESETS(x) DOC(bbp, sonata, NodeSets, x) +#define DOC_COMPARTMENTLOCATION(x) DOC(bbp, sonata, CompartmentLocation, x) +#define DOC_COMPARTMENTSET(x) DOC(bbp, sonata, CompartmentSet, x) +#define DOC_COMPARTMENTSETS(x) DOC(bbp, sonata, CompartmentSets, x) #define DOC_SEL(x) DOC(bbp, sonata, Selection, x) #define DOC_POP(x) DOC(bbp, sonata, Population, x) #define DOC_POP_NODE(x) DOC(bbp, sonata, NodePopulation, x) @@ -471,6 +475,10 @@ PYBIND11_MODULE(_libsonata, m) { .def( "flatten", [](Selection& obj) { return asArray(obj.flatten()); }, DOC_SEL(flatten)) .def_property_readonly("flat_size", &Selection::flatSize, DOC_SEL(flatSize)) + .def( + "__contains__", + [](const Selection& sel, uint64_t node_id) { return sel.contains(node_id); }, + DOC_SEL(nodeId)) .def( "__bool__", [](const Selection& obj) { return !obj.empty(); }, @@ -540,6 +548,123 @@ PYBIND11_MODULE(_libsonata, m) { .def("update", &NodeSets::update, "other"_a, DOC_NODESETS(update)) .def("toJSON", &NodeSets::toJSON, DOC_NODESETS(toJSON)); + py::class_(m, "CompartmentLocation") + .def("__eq__", &CompartmentLocation::operator==) + .def("__ne__", &CompartmentLocation::operator!=) + .def("__repr__", + [](const CompartmentLocation& self) { + return fmt::format("CompartmentLocation({}, {}, {})", + self.nodeId, + self.sectionIndex, + self.offset); + }) + .def("__str__", + [](const CompartmentLocation& self) { return py::str(py::repr(py::cast(self))); }) + .def_readonly("node_id", &CompartmentLocation::nodeId, DOC_COMPARTMENTLOCATION(nodeId)) + .def_readonly("section_index", + &CompartmentLocation::sectionIndex, + DOC_COMPARTMENTLOCATION(sectionIndex)) + .def_readonly("offset", &CompartmentLocation::offset, DOC_COMPARTMENTLOCATION(offset)); + + py::class_(m, "CompartmentSet") + .def(py::init()) + .def_property_readonly("population", + &CompartmentSet::population, + DOC_COMPARTMENTSET(population)) + .def("size", + py::overload_cast(&CompartmentSet::size, py::const_), + py::arg("selection") = bbp::sonata::Selection({}), + DOC_COMPARTMENTSET(size)) + .def("node_ids", &CompartmentSet::nodeIds, DOC_COMPARTMENTSET(nodeIds)) + .def("filter", + &CompartmentSet::filter, + py::arg("selection") = bbp::sonata::Selection({}), + DOC_COMPARTMENTSET(filter)) + .def( + "filtered_iter", + [](const CompartmentSet& self, const bbp::sonata::Selection& sel) { + auto range = self.filtered_crange(sel); + return py::make_iterator(range.first, range.second); + }, + py::arg("selection") = bbp::sonata::Selection({}), + py::keep_alive<0, 1>(), + DOC_COMPARTMENTSET(filteredIter)) + .def("__len__", [](const CompartmentSet& self) { return self.size(); }) + .def( + "__getitem__", + [](const CompartmentSet& self, py::ssize_t i) { + if (i < 0) { + i += static_cast(self.size()); + } + if (i < 0 || static_cast(i) >= self.size()) { + throw py::index_error("Index out of range"); + } + return self[static_cast(i)]; + }, + py::arg("index"), + DOC_COMPARTMENTSET(getitem)) + .def( + "__iter__", + [](const CompartmentSet& self) { + auto range = self.filtered_crange(bbp::sonata::Selection({})); + return py::make_iterator(range.first, range.second); + }, + py::keep_alive<0, 1>()) + .def("__eq__", + [](const CompartmentSet& self, const CompartmentSet& other) { return self == other; }) + .def("__ne__", + [](const CompartmentSet& self, const CompartmentSet& other) { return self != other; }) + .def("toJSON", &CompartmentSet::toJSON, DOC_COMPARTMENTSET(toJSON)) + .def("__repr__", + [](const CompartmentSet& self) { + auto range = self.filtered_crange(bbp::sonata::Selection({})); + std::vector parts; + for (auto it = range.first; it != range.second; ++it) { + parts.push_back(py::repr(py::cast(*it))); + } + auto joined = py::str(", ").attr("join")(parts); + return "CompartmentSet(population=" + + py::repr(py::cast(self.population())).cast() + + ", compartments=[" + joined.cast() + "])"; + }) + .def("__str__", + [](const CompartmentSet& self) { return py::str(py::repr(py::cast(self))); }); + + py::class_(m, "CompartmentSets") + .def(py::init()) + .def_static("fromFile", &CompartmentSets::fromFile, py::arg("path")) + .def("__contains__", + &CompartmentSets::contains, + py::arg("key"), + DOC_COMPARTMENTSETS(contains)) + .def("names", &CompartmentSets::names) + .def("values", &CompartmentSets::getAllCompartmentSets) + .def("items", &CompartmentSets::items) + .def("toJSON", &CompartmentSets::toJSON, DOC_COMPARTMENTSETS(toJSON)) + .def("__eq__", &CompartmentSets::operator==) + .def("__ne__", &CompartmentSets::operator!=) + .def("__len__", &CompartmentSets::size) + .def("__getitem__", + &CompartmentSets::getCompartmentSet, + py::arg("key"), + DOC_COMPARTMENTSET(getitem)) + .def("__repr__", + [](const CompartmentSets& self) { + auto items = self.items(); + std::vector parts; + for (const auto& item : items) { + // Build "key: value" strings + auto key_repr = py::repr(py::cast(item.first)); + auto val_repr = py::repr(py::cast(item.second)); + parts.push_back(key_repr.attr("__str__")() + py::str(": ") + + val_repr.attr("__str__")()); + } + auto joined = py::str(", ").attr("join")(parts); + return "CompartmentSets({" + joined.cast() + "})"; + }) + .def("__str__", + [](const CompartmentSets& self) { return py::str(py::repr(py::cast(self))); }); + py::class_(m, "CommonPopulationProperties", "Stores population-specific network information") @@ -1144,6 +1269,9 @@ PYBIND11_MODULE(_libsonata, m) { .def_property_readonly("node_sets_file", &SimulationConfig::getNodeSetsFile, DOC_SIMULATIONCONFIG(getNodeSetsFile)) + .def_property_readonly("compartment_sets_file", + &SimulationConfig::getCompartmentSetsFile, + DOC_SIMULATIONCONFIG(getCompartmentSetsFile)) .def_property_readonly("node_set", &SimulationConfig::getNodeSet, DOC_SIMULATIONCONFIG(getNodeSet)) diff --git a/python/generated/docstrings.h b/python/generated/docstrings.h index 37a40ab..b16bb2b 100644 --- a/python/generated/docstrings.h +++ b/python/generated/docstrings.h @@ -381,6 +381,38 @@ static const char *__doc_bbp_sonata_NodeSets_operator_assign = R"doc()doc"; static const char *__doc_bbp_sonata_NodeSets_toJSON = R"doc(Return the nodesets as a JSON string.)doc"; +static const char *__doc_bbp_sonata_CompartmentLocation_nodeId = R"doc(Id of the node.)doc"; + +static const char *__doc_bbp_sonata_CompartmentLocation_sectionIndex = R"doc(Absolute section index. Progressive index that uniquely identifies the section. There is a mapping between neuron section names (i.e. dend[10]) and this index.)doc"; + +static const char *__doc_bbp_sonata_CompartmentLocation_offset = R"doc(Offset of the compartment along the section)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_population = R"doc(Population name)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_toJSON = R"doc(Return the compartment set as a JSON string.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_size = R"doc(Return the size of the set, optionally filtered by selection.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_getitem = R"doc("Get a CompartmentLocation by index. Creates a copy of the object.")doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_nodeIds = R"doc(Node ids in the list of CompartmentLocations.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_filter = R"doc(Filter the compartment set based on a selection.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSet_filteredIter = R"doc(Iterator over CompartmentLocations filtered by selection.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_keys = R"doc(Return the keys of the CompartmentSets)doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_values = R"doc(Return the values of the CompartmentSets)doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_items = R"doc(Return the (key, value) pairs of the CompartmentSets)doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_contains = R"doc(Check if key exists.)doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_getitem = R"doc("Get a CompartmentSet by key.")doc"; + +static const char *__doc_bbp_sonata_CompartmentSets_toJSON = R"doc(Serialize CompartmentSets to a JSON string)doc"; + static const char *__doc_bbp_sonata_NodeSets_update = R"doc(Update `this` to include all nodesets from `this` and `other`. @@ -694,6 +726,8 @@ static const char *__doc_bbp_sonata_Selection_ranges = R"doc(Get a list of range static const char *__doc_bbp_sonata_Selection_ranges_2 = R"doc()doc"; +static const char *__doc_bbp_sonata_Selection_nodeId = R"doc(Check if a node id is contained in the selection)doc"; + static const char *__doc_bbp_sonata_SimulationConfig = R"doc(Read access to a SONATA simulation config file.)doc"; static const char *__doc_bbp_sonata_SimulationConfig_Conditions = R"doc(Parameters defining global experimental conditions.)doc"; @@ -1261,6 +1295,9 @@ static const char *__doc_bbp_sonata_SimulationConfig_getNodeSetsFile = R"doc(Returns the path of node sets file overriding node_sets_file provided in _network, default is empty in case of no setting in _network)doc"; +static const char *__doc_bbp_sonata_SimulationConfig_getCompartmentSetsFile = +R"doc(Returns the path of compartment sets file, default is empty in case of no setting in _network)doc"; + static const char *__doc_bbp_sonata_SimulationConfig_getOutput = R"doc(Returns the Output section of the simulation configuration.)doc"; static const char *__doc_bbp_sonata_SimulationConfig_getReport = @@ -1292,6 +1329,8 @@ static const char *__doc_bbp_sonata_SimulationConfig_nodeSet = R"doc()doc"; static const char *__doc_bbp_sonata_SimulationConfig_nodeSetsFile = R"doc()doc"; +static const char *__doc_bbp_sonata_SimulationConfig_compartmentSetsFile = R"doc()doc"; + static const char *__doc_bbp_sonata_SimulationConfig_output = R"doc()doc"; static const char *__doc_bbp_sonata_SimulationConfig_reports = R"doc()doc"; diff --git a/python/libsonata/__init__.py b/python/libsonata/__init__.py index 4416155..93fce0a 100644 --- a/python/libsonata/__init__.py +++ b/python/libsonata/__init__.py @@ -14,6 +14,9 @@ ElementReportReader, NodePopulation, NodeSets, + CompartmentLocation, + CompartmentSet, + CompartmentSets, NodeStorage, Selection, SomaDataFrame, @@ -38,6 +41,9 @@ "ElementReportReader", "NodePopulation", "NodeSets", + "CompartmentLocation", + "CompartmentSet", + "CompartmentSets", "NodeStorage", "Selection", "SomaDataFrame", diff --git a/python/tests/test_compartment_sets.py b/python/tests/test_compartment_sets.py new file mode 100644 index 0000000..d7e2ecf --- /dev/null +++ b/python/tests/test_compartment_sets.py @@ -0,0 +1,196 @@ +import json +import os +import unittest + +from libsonata import ( + CompartmentLocation, + CompartmentSet, + CompartmentSets, + Selection +) + +PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), + '../../tests/data') +class TestCompartmentLocation(unittest.TestCase): + def setUp(self): + self.json = '''{ + "population": "pop0", + "compartment_set": [ + [4, 40, 0.9], + [4, 40, 0.9], + [3, 30, 0.75] + ] + }''' + self.cs = CompartmentSet(self.json) + def test_constructor_from_values(self): + loc = self.cs[0] + self.assertEqual(loc.node_id, 4) + self.assertEqual(loc.section_index, 40) + self.assertAlmostEqual(loc.offset, 0.9) + + def test_equality(self): + self.assertEqual(self.cs[0], self.cs[1]) + self.assertNotEqual(self.cs[0], self.cs[2]) + + def test_repr_and_str(self): + loc = self.cs[0] + expected = "CompartmentLocation(4, 40, 0.9)" + self.assertEqual(repr(loc), expected) + self.assertEqual(str(loc), repr(loc)) # str should delegate to repr +class TestCompartmentSet(unittest.TestCase): + def setUp(self): + self.json = '''{ + "population": "pop0", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25], + [3, 30, 0.75], + [2, 20, 0.25] + ] + }''' + self.cs = CompartmentSet(self.json) + + def test_population_property(self): + self.assertIsInstance(self.cs.population, str) + self.assertEqual(self.cs.population, "pop0") + + def test_size(self): + self.assertEqual(self.cs.size(), 4) + self.assertEqual(self.cs.size([1, 2]), 3) + self.assertEqual(self.cs.size(Selection([[2, 3]])), 2) + + def test_len_dunder(self): + self.assertEqual(len(self.cs), 4) + + def test_getitem(self): + loc = self.cs[0] + self.assertEqual((loc.node_id, loc.section_index, loc.offset), (1, 10, 0.5)) + + def test_getitem_negative_index(self): + loc = self.cs[-1] + self.assertEqual((loc.node_id, loc.section_index, loc.offset), (2, 20, 0.25)) + + def test_getitem_out_of_bounds_raises(self): + with self.assertRaises(IndexError): + _ = self.cs[10] + with self.assertRaises(IndexError): + _ = self.cs[-10] + + def test_iterators(self): + node_ids = [loc.node_id for loc in self.cs] + self.assertEqual(node_ids, [1, 2, 3, 2]) + node_ids = [loc.node_id for loc in self.cs.filtered_iter([2, 3])] + self.assertEqual(node_ids, [2, 3, 2]) + conv_to_list = list(self.cs.filtered_iter([2, 3])) + self.assertTrue(all(isinstance(i, CompartmentLocation) for i in conv_to_list)) + + def test_node_ids(self): + node_ids = self.cs.node_ids() + self.assertEqual(node_ids, Selection([1, 2, 3])) + + def test_filter_identity(self): + filtered = self.cs.filter() + self.assertEqual(filtered.size(), 4) + filtered = self.cs.filter(Selection([1, 2])) + self.assertEqual(filtered.size(), 3) + + def test_toJSON_roundtrip(self): + json_out = self.cs.toJSON() + cs2 = CompartmentSet(json_out) + self.assertEqual(cs2, self.cs) + + def test_equality(self): + cs1 = CompartmentSet(self.json) + cs2 = CompartmentSet(self.json) + self.assertEqual(cs1, cs2) + self.assertFalse(cs1 != cs2) + + # Slightly modify JSON to create a different object + json_diff = '''{ + "population": "pop0", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25], + [3, 30, 0.75] + ] + }''' + cs3 = CompartmentSet(json_diff) + self.assertNotEqual(cs1, cs3) + self.assertFalse(cs1 == cs3) + + def test_repr_and_str(self): + r = repr(self.cs) + s = str(self.cs) + print(r) + self.assertTrue(r.startswith("CompartmentSet(population")) + self.assertEqual(s, r) + +class TestCompartmentSets(unittest.TestCase): + def setUp(self): + # Load valid json string from file + with open(os.path.join(PATH, 'compartment_sets.json'), 'r') as f: + self.json_str = f.read() + self.cs = CompartmentSets(self.json_str) + + def test_init_from_string(self): + self.assertIsInstance(self.cs, CompartmentSets) + self.assertGreater(len(self.cs), 0) + + + def test_contains(self): + keys = self.cs.names() + for key in keys: + self.assertIn(key, self.cs) + self.assertNotIn('non_existing_key', self.cs) + + def test_getitem(self): + keys = self.cs.names() + if keys: + key = keys[0] + val = self.cs[key] + self.assertIsInstance(val, CompartmentSet) + + def test_keys_values_items(self): + keys = self.cs.names() + values = self.cs.values() + items = self.cs.items() + self.assertEqual(len(keys), len(values)) + self.assertEqual(len(keys), len(items)) + for k, v in items: + self.assertIn(k, keys) + self.assertIn(v, values) + + def test_equality(self): + cs1 = CompartmentSets(self.json_str) + cs2 = CompartmentSets(self.json_str) + self.assertEqual(cs1, cs2) + self.assertFalse(cs1 != cs2) + + # Modify JSON to create different object + altered = json.loads(self.json_str) + if altered: + # Remove one key if possible + some_key = list(altered.keys())[0] + altered.pop(some_key) + altered_json = json.dumps(altered) + cs3 = CompartmentSets(altered_json) + self.assertNotEqual(cs1, cs3) + self.assertFalse(cs1 == cs3) + + def test_toJSON_roundtrip(self): + json_out = self.cs.toJSON() + cs2 = CompartmentSets(json_out) + self.assertEqual(self.cs, cs2) + + def test_static_fromFile(self): + cs_file = CompartmentSets.fromFile(os.path.join(PATH, 'compartment_sets.json')) + self.assertEqual(cs_file, self.cs) + + def test_repr_and_str(self): + r = repr(self.cs) + s = str(self.cs) + self.assertTrue(r.startswith("CompartmentSets({")) + self.assertEqual(s, r) # str delegates to repr + # repr should contain keys from the dict + for key in self.cs.names(): + self.assertIn(str(key), r) \ No newline at end of file diff --git a/python/tests/test_config.py b/python/tests/test_config.py index 94b8388..5391620 100644 --- a/python/tests/test_config.py +++ b/python/tests/test_config.py @@ -471,10 +471,11 @@ def test_basic(self): self.assertEqual(self.config.network, os.path.abspath(os.path.join(PATH, 'config/circuit_config.json'))) - self.assertEqual(self.config.target_simulator.name, 'CORENEURON'); - circuit_conf = CircuitConfig.from_file(self.config.network); - self.assertEqual(self.config.node_sets_file, circuit_conf.node_sets_path); - self.assertEqual(self.config.node_set, 'Column'); + self.assertEqual(self.config.target_simulator.name, 'CORENEURON') + circuit_conf = CircuitConfig.from_file(self.config.network) + self.assertEqual(self.config.node_sets_file, circuit_conf.node_sets_path) + self.assertEqual(self.config.compartment_sets_file, os.path.abspath(os.path.join(PATH, 'config/compartment_sets.json'))) + self.assertEqual(self.config.node_set, 'Column') self.assertEqual(self.config.list_input_names, {"ex_abs_shotnoise", diff --git a/setup.py b/setup.py index a662ed4..f5c4903 100644 --- a/setup.py +++ b/setup.py @@ -112,11 +112,8 @@ def build_extension(self, ext): author="Blue Brain Project, EPFL", long_description=README, long_description_content_type='text/x-rst', - license="LGPLv3", + license="LGPL-3.0-or-later", url='https://github.com/openbraininstitute/libsonata', - classifiers=[ - "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)", - ], ext_modules=[CMakeExtension("libsonata._libsonata")], cmdclass={'build_ext': CMakeBuild, }, diff --git a/src/compartment_sets.cpp b/src/compartment_sets.cpp new file mode 100644 index 0000000..6f2f283 --- /dev/null +++ b/src/compartment_sets.cpp @@ -0,0 +1,564 @@ + + +#include "../extlib/filesystem.hpp" + +#include "utils.h" // readFile + +#include + +#include +namespace bbp { +namespace sonata { + +namespace fs = ghc::filesystem; + +namespace detail { + +using json = nlohmann::json; + + +class CompartmentSetFilteredIterator { +public: + using base_iterator = std::vector::const_iterator; + using value_type = CompartmentLocation; + using reference = const value_type&; + using pointer = const value_type*; + using difference_type = std::ptrdiff_t; + using iterator_category = std::forward_iterator_tag; + + private: + base_iterator current_; + base_iterator end_; + bbp::sonata::Selection selection_; // copied + + void skip_to_valid() { + while (current_ != end_) { + if (selection_.empty() || selection_.contains(current_->nodeId)) { + break; + } + ++current_; + } + } + +public: + + CompartmentSetFilteredIterator(base_iterator current, + base_iterator end, + bbp::sonata::Selection selection) + : current_(current), end_(end), selection_(std::move(selection)) { + skip_to_valid(); + } + CompartmentSetFilteredIterator(base_iterator end) + : current_(end) + , end_(end) + , selection_({}) /* empty selection */ { } + + reference operator*() const { + return *current_; + } + + pointer operator->() const { + return &(*current_); + } + + CompartmentSetFilteredIterator& operator++() { + ++current_; + skip_to_valid(); + return *this; + } + + CompartmentSetFilteredIterator operator++(int) { + CompartmentSetFilteredIterator tmp = *this; + ++(*this); + return tmp; + } + + bool operator==(const CompartmentSetFilteredIterator& other) const { + return current_ == other.current_; + } + + bool operator!=(const CompartmentSetFilteredIterator& other) const { + return !(*this == other); + } + + std::unique_ptr clone() const { + return std::make_unique(*this); + } +}; + +class CompartmentSet { +public: + using container_t = std::vector; + class FilteredIterator; +private: + // Private constructor for filter factory method + + std::string population_; + container_t compartment_locations_; + + + /** + * Copy-construction is private. Used only for cloning. + */ + CompartmentSet(const CompartmentSet& other) = default; + CompartmentSet(const std::string& population, const container_t& compartment_locations): population_(population), compartment_locations_(compartment_locations) {} + + static CompartmentLocation _parseCompartmentLocation(const nlohmann::json& j) { + if (!j.is_array() || j.size() != 3) { + throw SonataError( + "CompartmentLocation must be an array of exactly 3 elements: [node_id, " + "section_index, " + "offset]"); + } + + const uint64_t node_id = get_uint64_or_throw(j[0]); + const uint64_t section_index = get_uint64_or_throw(j[1]); + if (!j[2].is_number()) { + throw SonataError("Offset (third element) must be a number"); + } + const double offset = j[2].get(); + if (offset < 0.0 || offset > 1.0) { + throw SonataError( + fmt::format("Offset must be between 0 and 1 inclusive, got {}", offset)); + } + + return {node_id, section_index, offset}; + } + +public: + + // Construct from JSON string (delegates to JSON constructor) + explicit CompartmentSet(const std::string& content) + : CompartmentSet(nlohmann::json::parse(content)) {} + + // Construct from JSON object + explicit CompartmentSet(const nlohmann::json& j) { + if (!j.is_object()) { + throw SonataError("CompartmentSet must be an object"); + } + + auto pop_it = j.find("population"); + if (pop_it == j.end() || !pop_it->is_string()) { + throw SonataError("CompartmentSet must contain 'population' key of string type"); + } + population_ = pop_it->get(); + + auto comp_it = j.find("compartment_set"); + if (comp_it == j.end() || !comp_it->is_array()) { + throw SonataError("CompartmentSet must contain 'compartment_set' key of array type"); + } + + compartment_locations_.reserve(comp_it->size()); + for (auto&& el : *comp_it) { + compartment_locations_.emplace_back(CompartmentSet::_parseCompartmentLocation(el)); + } + compartment_locations_.shrink_to_fit(); + } + + ~CompartmentSet() = default; + CompartmentSet& operator=(const CompartmentSet&) = delete; + CompartmentSet(CompartmentSet&&) noexcept = default; + CompartmentSet& operator=(CompartmentSet&&) noexcept = default; + + std::pair + filtered_crange(bbp::sonata::Selection selection = Selection({})) const { + CompartmentSetFilteredIterator begin_it(compartment_locations_.cbegin(), + compartment_locations_.cend(), + selection); + CompartmentSetFilteredIterator end_it(compartment_locations_.cend()); + return {begin_it, end_it}; + } + + // Size with optional filter + std::size_t size(const bbp::sonata::Selection& selection = bbp::sonata::Selection({})) const { + if (selection.empty()) { + return compartment_locations_.size(); + } + + return static_cast(std::count_if(compartment_locations_.begin(), + compartment_locations_.end(), + [&](const CompartmentLocation& loc) { + return selection.contains(loc.nodeId); + })); + } + + std::size_t empty() const { + return compartment_locations_.empty(); + } + + const CompartmentLocation& operator[](std::size_t index) const { + return compartment_locations_.at(index); + } + + Selection nodeIds() const { + std::vector result; + std::unordered_set seen; + + result.reserve(compartment_locations_.size()); + for (const auto& elem : compartment_locations_) { + uint64_t id = elem.nodeId; + if (seen.insert(id).second) { // insert returns {iterator, bool} + result.push_back(id); + } + } + sort(result.begin(), result.end()); + return Selection::fromValues(result.begin(), result.end()); + } + + const std::string& population() const { + return population_; + } + + nlohmann::json to_json() const { + nlohmann::json j; + j["population"] = population_; + + j["compartment_set"] = nlohmann::json::array(); + for (const auto& elem : compartment_locations_) { + j["compartment_set"].push_back( + nlohmann::json::array({elem.nodeId, elem.sectionIndex, elem.offset})); + } + + return j; + } + + std::unique_ptr clone() const { + return std::unique_ptr(new CompartmentSet(*this)); + } + + std::unique_ptr filter(const bbp::sonata::Selection& selection = bbp::sonata::Selection({})) const { + if (selection.empty()) { + return clone(); + } + std::vector filtered; + filtered.reserve(compartment_locations_.size()); + for (const auto& el : compartment_locations_) { + if (selection.contains(el.nodeId)) { + filtered.emplace_back(el); + } + } + return std::unique_ptr(new CompartmentSet(population_, std::move(filtered))); + } + + bool operator==(const CompartmentSet& other) const { + return (population_ == other.population_) && + (compartment_locations_ == other.compartment_locations_); + } + + bool operator!=(const CompartmentSet& other) const { + return !(*this == other); + } +}; +class CompartmentSets +{ +private: + std::map> data_; + +public: + CompartmentSets(const json& j) { + if (!j.is_object()) { + throw SonataError("Top level compartment_set must be an object"); + } + + for (const auto& el : j.items()) { + data_.emplace(el.key(), std::make_shared(el.value())); + } + } + + static const fs::path& validate_path(const fs::path& path) { + if (!fs::exists(path)) { + throw SonataError(fmt::format("Path does not exist: {}", std::string(path))); + } + return path; + } + + CompartmentSets(const fs::path& path) + : CompartmentSets(json::parse(std::ifstream(validate_path(path)))) {} + + static CompartmentSets fromFile(const std::string& path_) { + fs::path path(path_); + return path; + } + + CompartmentSets(const std::string& content) + : CompartmentSets(json::parse(content)) {} + + + std::shared_ptr getCompartmentSet(const std::string& key) const { + return data_.at(key); + } + + std::size_t size() const { + return data_.size(); + } + + bool contains(const std::string& key) const { + return data_.find(key) != data_.end(); + } + + bool empty() const { + return data_.empty(); + } + + std::vector names() const { + std::vector result; + result.reserve(data_.size()); + std::transform(data_.begin(), data_.end(), std::back_inserter(result), + [](const auto& kv) { return kv.first; }); + return result; + } + + std::vector> getAllCompartmentSets() const { + std::vector> result; + result.reserve(data_.size()); + std::transform(data_.begin(), data_.end(), std::back_inserter(result), + [](const auto& kv) { return kv.second; }); + return result; + } + + std::vector>> items() const { + std::vector>> result; + result.reserve(data_.size()); + std::copy(data_.begin(), data_.end(), std::back_inserter(result)); + return result; + } + + nlohmann::json to_json() const { + nlohmann::json j; + for (const auto& entry : data_) { + j[entry.first] = entry.second->to_json(); + } + return j; + } + + bool operator==(const CompartmentSets& other) { + if (data_.size() != other.data_.size()) { + return false; + } + + for (const auto& kv : data_) { + const auto& key = kv.first; + const auto& this_set = kv.second; + + auto it = other.data_.find(key); + if (it == other.data_.end()) { + return false; + } + + const auto& other_set = it->second; + if (this_set == nullptr && other_set == nullptr) { + continue; + } + if (this_set == nullptr || other_set == nullptr) { + return false; + } + if (*this_set != *other_set) { + return false; + } + } + + return true; + } + + bool operator!=(const CompartmentSets& other) { + return !(*this == other); + } +}; + +} // namespace detail + +// CompartmentSetFilteredIterator public API + +// Constructor +CompartmentSetFilteredIterator::CompartmentSetFilteredIterator(std::unique_ptr impl) + : impl_(std::move(impl)) {} + +// Copy constructor +CompartmentSetFilteredIterator::CompartmentSetFilteredIterator(const CompartmentSetFilteredIterator& other) + : impl_(other.impl_ ? other.impl_->clone() : nullptr) {} + +// Copy assignment operator +CompartmentSetFilteredIterator& CompartmentSetFilteredIterator::operator=(const CompartmentSetFilteredIterator& other) { + if (this != &other) { + impl_ = other.impl_ ? other.impl_->clone() : nullptr; + } + return *this; +} + +// Move constructor +CompartmentSetFilteredIterator::CompartmentSetFilteredIterator(CompartmentSetFilteredIterator&&) noexcept = default; + +// Move assignment operator +CompartmentSetFilteredIterator& CompartmentSetFilteredIterator::operator=(CompartmentSetFilteredIterator&&) noexcept = default; + + +CompartmentSetFilteredIterator::~CompartmentSetFilteredIterator() = default; + +const CompartmentLocation& CompartmentSetFilteredIterator::operator*() const { + return impl_->operator*(); +} + +const CompartmentLocation* CompartmentSetFilteredIterator::operator->() const { + return impl_->operator->(); +} + +CompartmentSetFilteredIterator& CompartmentSetFilteredIterator::operator++() { + ++(*impl_); + return *this; +} + +CompartmentSetFilteredIterator CompartmentSetFilteredIterator::operator++(int) { + CompartmentSetFilteredIterator tmp(std::make_unique(*impl_)); + ++(*impl_); + return tmp; +} + +bool CompartmentSetFilteredIterator::operator==(const CompartmentSetFilteredIterator& other) const { + return *impl_ == *other.impl_; +} + +bool CompartmentSetFilteredIterator::operator!=(const CompartmentSetFilteredIterator& other) const { + return !(*this == other); +} + +// CompartmentSet public API + +CompartmentSet::CompartmentSet(const std::string& json_content) + : impl_(std::make_shared(json_content)) {} + +CompartmentSet::CompartmentSet(std::shared_ptr&& impl) + : impl_(std::move(impl)) {} + + +std::pair +CompartmentSet::filtered_crange(bbp::sonata::Selection selection) const { + const auto internal_result = impl_->filtered_crange(std::move(selection)); + + // Wrap clones of detail iterators in public API iterators + return { + CompartmentSetFilteredIterator(internal_result.first.clone()), + CompartmentSetFilteredIterator(internal_result.second.clone()) + }; +} + +std::size_t CompartmentSet::size(const bbp::sonata::Selection& selection) const { + return impl_->size(selection); +} + +bool CompartmentSet::empty() const { + return impl_->empty(); +} + +const std::string& CompartmentSet::population() const { + return impl_->population(); +} + +CompartmentLocation CompartmentSet::operator[](std::size_t index) const { + return CompartmentLocation((*impl_)[index]); +} + +bbp::sonata::Selection CompartmentSet::nodeIds() const { + return impl_->nodeIds(); +} + +CompartmentSet CompartmentSet::filter(const bbp::sonata::Selection& selection) const { + return CompartmentSet(impl_->filter(selection)); +} + +bool CompartmentSet::operator==(const CompartmentSet& other) const { + return *impl_ == *(other.impl_); +} + +bool CompartmentSet::operator!=(const CompartmentSet& other) const { + return *impl_ != *(other.impl_); +} + +std::string CompartmentSet::toJSON() const { + return impl_->to_json().dump(); +} + +// CompartmentSets public API + +CompartmentSets::CompartmentSets(const std::string& content) + : impl_(new detail::CompartmentSets(content)) {} +CompartmentSets::CompartmentSets(std::unique_ptr&& impl) + : impl_(std::move(impl)) {} +CompartmentSets::CompartmentSets(detail::CompartmentSets&& impl) + : CompartmentSets(std::make_unique(impl)) {} + +CompartmentSets::CompartmentSets(CompartmentSets&&) noexcept = default; +CompartmentSets& CompartmentSets::operator=(CompartmentSets&&) noexcept = default; +CompartmentSets::~CompartmentSets() = default; + + + +CompartmentSets CompartmentSets::fromFile(const std::string& path) { + return detail::CompartmentSets::fromFile(path); +} + +CompartmentSet CompartmentSets::getCompartmentSet(const std::string& key) const { + return CompartmentSet(impl_->getCompartmentSet(key)); +} + +// Number of compartment sets +std::size_t CompartmentSets::size() const { + return impl_->size(); +} + +// is empty? +bool CompartmentSets::empty() const { + return impl_->empty(); +} + +// Check if key exists +bool CompartmentSets::contains(const std::string& key) const { + return impl_->contains(key); +} + +// Get keys as set or vector (use vector here) +std::vector CompartmentSets::names() const { + return impl_->names(); +} + +// Get all compartment sets as vector +std::vector CompartmentSets::getAllCompartmentSets() const { + const auto vals = impl_->getAllCompartmentSets(); + std::vector result; + result.reserve(vals.size()); + std::transform(vals.begin(), vals.end(), std::back_inserter(result), + [](std::shared_ptr ptr) { return CompartmentSet(std::move(ptr)); }); + + return result; +} + +// Get items (key + compartment set) as vector of pairs +std::vector> CompartmentSets::items() const { + auto items_vec = impl_->items(); + + std::vector> result; + result.reserve(items_vec.size()); + + std::transform( + items_vec.begin(), items_vec.end(), + std::back_inserter(result), + [](auto kv) { // pass by value to own the shared_ptr + return std::make_pair(std::move(kv.first), CompartmentSet(std::move(kv.second))); + }); + + return result; +} +// Serialize all compartment sets to JSON string +std::string CompartmentSets::toJSON() const { + return impl_->to_json().dump(); +} + +bool CompartmentSets::operator==(const CompartmentSets& other) const { + return *impl_ == *(other.impl_); +} + +bool CompartmentSets::operator!=(const CompartmentSets& other) const { + return *impl_ != *(other.impl_); +} + +} // namespace sonata +} // namespace bbp \ No newline at end of file diff --git a/src/config.cpp b/src/config.cpp index 89227bd..f27a8cf 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -1210,6 +1210,15 @@ class SimulationConfig::Parser } } + std::string parseCompartmentSetsFile() const noexcept { + std::string val; + if (_json.contains("compartment_sets_file")) { + val = _json["compartment_sets_file"]; + return toAbsolute(_basePath, val); + } + return val; + } + nonstd::optional parseNodeSet() const { if (_json.contains("node_set")) { return {_json["node_set"]}; @@ -1383,6 +1392,7 @@ SimulationConfig::SimulationConfig(const std::string& content, const std::string _connection_overrides = parser.parseConnectionOverrides(); _targetSimulator = parser.parseTargetSimulator(); _nodeSetsFile = parser.parseNodeSetsFile(); + _compartmentSetsFile = parser.parseCompartmentSetsFile(); _nodeSet = parser.parseNodeSet(); _metaData = parser.parseMetaData(); _betaFeatures = parser.parseBetaFeatures(); @@ -1457,6 +1467,10 @@ const std::string& SimulationConfig::getNodeSetsFile() const noexcept { return _nodeSetsFile; } +const std::string& SimulationConfig::getCompartmentSetsFile() const noexcept { + return _compartmentSetsFile; +} + const nonstd::optional& SimulationConfig::getNodeSet() const noexcept { return _nodeSet; } diff --git a/src/node_sets.cpp b/src/node_sets.cpp index 8ce0e5c..98149bd 100644 --- a/src/node_sets.cpp +++ b/src/node_sets.cpp @@ -462,26 +462,6 @@ class NodeSetCompoundRule: public NodeSetRule CompoundTargets targets_; }; -int64_t get_int64_or_throw(const json& el) { - auto v = el.get(); - if (std::floor(v) != v) { - throw SonataError(fmt::format("expected integer, got float {}", v)); - } - return static_cast(v); -} - -uint64_t get_uint64_or_throw(const json& el) { - auto v = el.get(); - if (v < 0) { - throw SonataError(fmt::format("expected unsigned integer, got {}", v)); - } - - if (std::floor(v) != v) { - throw SonataError(fmt::format("expected integer, got float {}", v)); - } - return static_cast(v); -} - NodeSetRulePtr _dispatch_node(const std::string& attribute, const json& value) { if (value.is_number()) { if (attribute == "population") { diff --git a/src/selection.cpp b/src/selection.cpp index 1c22b65..a527652 100644 --- a/src/selection.cpp +++ b/src/selection.cpp @@ -61,7 +61,6 @@ Selection union_(const Ranges& lhs, const Ranges& rhs) { } } // namespace detail - Selection::Selection(Selection::Ranges ranges) : ranges_(std::move(ranges)) { detail::_checkRanges(ranges_); @@ -119,6 +118,16 @@ Selection operator|(const Selection& lhs, const Selection& rhs) { return detail::union_(lhs.ranges(), rhs.ranges()); } +bool Selection::contains(Value node_id) const { + Ranges ret; + std::copy(ranges_.begin(), ranges_.end(), std::back_inserter(ret)); + ret = detail::_sortAndMerge(ret); + auto it = std::lower_bound(ret.begin(), ret.end(), node_id, [](const Range& range, Value v) { + return range[1] <= v; // Keep searching if node_id >= end + }); + + return it != ret.end() && (*it)[0] <= node_id && node_id < (*it)[1]; +} } // namespace sonata } // namespace bbp diff --git a/src/utils.h b/src/utils.h index df4e3f2..d39aed7 100644 --- a/src/utils.h +++ b/src/utils.h @@ -16,6 +16,8 @@ #include #include +#include +#include std::string readFile(const std::string& path); @@ -44,5 +46,47 @@ std::set getMapKeys(const T& map) { }); return ret; } + +using json = nlohmann::json; + +inline int64_t get_int64_or_throw(const json& el) { + if (!el.is_number()) { + throw SonataError(fmt::format("expected integer, got {}", el.dump())); + } + + auto v = el.get(); + if (std::floor(v) != v) { + throw SonataError(fmt::format("expected integer, got float {}", v)); + } + + if (v < static_cast(std::numeric_limits::min()) || + v > static_cast(std::numeric_limits::max())) { + throw SonataError(fmt::format("value {} out of int64_t bounds", v)); + } + + return static_cast(v); +} + +inline uint64_t get_uint64_or_throw(const json& el) { + if (!el.is_number()) { + throw SonataError(fmt::format("expected unsigned integer, got {}", el.dump())); + } + + auto v = el.get(); + if (v < 0) { + throw SonataError(fmt::format("expected unsigned integer, got negative value {}", v)); + } + + if (std::floor(v) != v) { + throw SonataError(fmt::format("expected unsigned integer, got float {}", v)); + } + + if (v > static_cast(std::numeric_limits::max())) { + throw SonataError(fmt::format("value {} out of uint64_t bounds", v)); + } + + return static_cast(v); +} + } // namespace sonata } // namespace bbp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6f57bc8..667f28b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,6 @@ set(TESTS_SRC main.cpp + test_compartment_sets.cpp test_config.cpp test_edges.cpp test_node_sets.cpp diff --git a/tests/data/compartment_sets.json b/tests/data/compartment_sets.json new file mode 100644 index 0000000..f141cb8 --- /dev/null +++ b/tests/data/compartment_sets.json @@ -0,0 +1,16 @@ + { + "cs1": { + "population": "pop1", + "compartment_set": [ + [0, 10, 0.1], + [0, 10, 0.2], + [0, 10, 0.1], + [2, 3, 0.1], + [3, 6, 0.3] + ] + }, + "cs0": { + "population": "pop0", + "compartment_set": [] + } + } \ No newline at end of file diff --git a/tests/data/config/simulation_config.json b/tests/data/config/simulation_config.json index 42e579c..594750a 100644 --- a/tests/data/config/simulation_config.json +++ b/tests/data/config/simulation_config.json @@ -13,10 +13,12 @@ }, "manifest": { "$OUTPUT_DIR": "./some/path", - "$ELECTRODES_DIR": "./electrodes/" + "$ELECTRODES_DIR": "./electrodes/", + "$BASE_DIR": "./" }, "target_simulator": "CORENEURON", "node_set" : "Column", + "compartment_sets_file": "$BASE_DIR/compartment_sets.json", "run": { "tstop": 1000, "dt": 0.025, diff --git a/tests/test_compartment_sets.cpp b/tests/test_compartment_sets.cpp new file mode 100644 index 0000000..7fc37e1 --- /dev/null +++ b/tests/test_compartment_sets.cpp @@ -0,0 +1,399 @@ +#include +#include +#include + +#include + +using namespace bbp::sonata; +using json = nlohmann::json; + +TEST_CASE("CompartmentLocation public API") { + + std::string json_content = R"( + { + "population": "test_population", + "compartment_set": [ + [1, 10, 0.5] + ] + } + )"; + CompartmentSet cs(json_content); + + SECTION("Construct from valid nodeId, section_idx, offset") { + const auto& loc = cs[0]; + REQUIRE(loc.nodeId == 1); + REQUIRE(loc.sectionIndex == 10); + REQUIRE(loc.offset == Approx(0.5)); + REQUIRE(cs[0] == CompartmentLocation{1, 10, 0.5}); + } + + SECTION("Invalid JSON string throws") { + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ ["bla", 2, 0.1] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, 2] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, 2, 0.1, 1] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ ["a", 2, 0.5] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, "a", 0.5] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, 2, "a"] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, 2, 2.0] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, 2, -0.1] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [-1, 2, 0.1] ]})"), SonataError); + REQUIRE_THROWS_AS(CompartmentSet(R"({"population": "pop0", "compartment_set": [ [1, -2, 0.1] ]})"), SonataError); + } + + SECTION("Equality operators") { + CompartmentLocation loc1{1, 10, 0.5}; + CompartmentLocation loc2{1, 10, 0.5}; + CompartmentLocation loc3{1, 10, 0.6}; + + REQUIRE(loc1 == loc2); + REQUIRE_FALSE(loc1 != loc2); + REQUIRE(loc1 != loc3); + } +} + + +TEST_CASE("CompartmentSet public API") { + + // Example JSON representing a CompartmentSet (adjust to match real expected format) + std::string json_content = R"( + { + "population": "test_population", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25], + [3, 30, 0.75], + [2, 20, 0.25] + ] + } + )"; + + SECTION("Construct from JSON string, round-trip serialization") { + CompartmentSet cs(json_content); + + REQUIRE(cs.population() == "test_population"); + REQUIRE(cs.size() == 4); + + // Access elements by index + REQUIRE(cs[0] == CompartmentLocation{1, 10, 0.5}); + REQUIRE(cs[1] == CompartmentLocation{2, 20, 0.25}); + REQUIRE(cs[2] == CompartmentLocation{3, 30, 0.75}); + REQUIRE(cs[3] == CompartmentLocation{2, 20, 0.25}); + REQUIRE_THROWS_AS(cs[4], std::out_of_range); + REQUIRE(cs.toJSON() == json::parse(json_content).dump()); + } + + SECTION("JSON constructor throws on invalid input") { + // Not an object (array instead) + REQUIRE_THROWS_AS(CompartmentSet("[1, 2, 3]"), SonataError); + + // Missing population key + REQUIRE_THROWS_AS( + CompartmentSet(R"({"compartment_set": []})"), + SonataError + ); + + // population not a string + REQUIRE_THROWS_AS( + CompartmentSet(R"({"population": 123, "compartment_set": []})"), + SonataError + ); + + // Missing compartment_set key + REQUIRE_THROWS_AS( + CompartmentSet(R"({"population": "test_population"})"), + SonataError + ); + + // compartment_set not an array + REQUIRE_THROWS_AS( + CompartmentSet(R"({"population": "test_population", "compartment_set": "not an array"})"), + SonataError + ); + } + + + SECTION("Size with selection filter") { + CompartmentSet cs(json_content); + + REQUIRE(cs.size(Selection::fromValues({1, 2})) == 3); + REQUIRE(cs.size(Selection::fromValues({3, 8, 9, 10, 13})) == 1); + REQUIRE(cs.size(Selection::fromValues({999})) == 0); + } + + SECTION("Filtered iteration") { + CompartmentSet cs(json_content); + + auto pp = cs.filtered_crange(); + + std::vector nodeIds; + for (auto it = pp.first; it != pp.second; ++it) { + nodeIds.push_back((*it).nodeId); + } + + REQUIRE(nodeIds.size() == 4); + REQUIRE((nodeIds == std::vector{1, 2, 3, 2})); + nodeIds.clear(); + for (auto it = cs.filtered_crange(Selection::fromValues({2, 3})).first; it != pp.second; ++it) { + nodeIds.push_back((*it).nodeId); + } + REQUIRE(nodeIds.size() == 3); + REQUIRE((nodeIds == std::vector{2, 3, 2})); + } + + SECTION("Filter returns subset") { + CompartmentSet cs(json_content); + auto filtered = cs.filter(Selection::fromValues({2, 3})); + + REQUIRE(filtered.size() == 3); + + // Check filtered compartments only contain nodeIds 1 and 2 + auto nodeIds = filtered.nodeIds().flatten(); + REQUIRE(nodeIds == std::vector({2, 3})); + auto no_filtered = cs.filter(); + REQUIRE(no_filtered.size() == 4); + auto no_filtered_nodeIds = no_filtered.nodeIds().flatten(); + REQUIRE(no_filtered_nodeIds == std::vector({1, 2, 3})); + } + + SECTION("Equality and inequality operators") { + std::string json1 = R"( + { + "population": "pop1", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25] + ] + } + )"; + + std::string json2 = R"( + { + "population": "pop1", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25] + ] + } + )"; + + std::string json_different = R"( + { + "population": "pop1", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.3] + ] + } + )"; + + std::string json_different2 = R"( + { + "population": "pop2", + "compartment_set": [ + [1, 10, 0.5], + [2, 20, 0.25] + ] + } + )"; + + CompartmentSet cs1(json1); + CompartmentSet cs2(json2); + CompartmentSet cs3(json_different); + CompartmentSet cs4(json_different2); + + REQUIRE(cs1 == cs2); + REQUIRE_FALSE(cs1 != cs2); + + REQUIRE(cs1 != cs3); + REQUIRE_FALSE(cs1 == cs3); + REQUIRE_FALSE(cs1 == cs4); + } + +} + +TEST_CASE("CompartmentSets public API") { + + const std::string json = R"({ + "cs1": { + "population": "pop1", + "compartment_set": [ + [0, 10, 0.1], + [0, 10, 0.2], + [0, 10, 0.1], + [2, 3, 0.1], + [3, 6, 0.3] + ] + }, + "cs0": { + "population": "pop0", + "compartment_set": [] + } + })"; + + const auto cs1 = CompartmentSet(R"({ + "population": "pop1", + "compartment_set": [ + [0, 10, 0.1], + [0, 10, 0.2], + [0, 10, 0.1], + [2, 3, 0.1], + [3, 6, 0.3] + ] + })"); + + const auto cs0 = CompartmentSet(R"({ + "population": "pop0", + "compartment_set": [] + })"); + + SECTION("Load from file and basic properties") { + auto sets = CompartmentSets::fromFile("./data/compartment_sets.json"); + + CHECK(sets.size() == 2); + CHECK_FALSE(sets.empty()); + + auto keys = sets.names(); + REQUIRE(sets.names() == std::vector{"cs0", "cs1"}); + + CHECK(sets.contains("cs0")); + CHECK(sets.contains("cs1")); + + const auto& cs0 = sets.getCompartmentSet("cs0"); + CHECK(cs0.empty()); + + const auto& cs1 = sets.getCompartmentSet("cs1"); + CHECK_FALSE(cs1.empty()); + } + + SECTION("Equality operator from file and string") { + // Load from file + auto sets_from_file = CompartmentSets::fromFile("./data/compartment_sets.json"); + + // The JSON string as in the file + const std::string json = R"({ + "cs1": { + "population": "pop1", + "compartment_set": [ + [0, 10, 0.1], + [0, 10, 0.2], + [0, 10, 0.1], + [2, 3, 0.1], + [3, 6, 0.3] + ] + }, + "cs0": { + "population": "pop0", + "compartment_set": [] + } + })"; + + // Construct from JSON string directly + CompartmentSets sets_from_string(json); + + // They should be equal + CHECK(sets_from_file == sets_from_string); + CHECK_FALSE(sets_from_file != sets_from_string); + + // Now change the string slightly and check inequality + const std::string json_modified = R"({ + "cs1": { + "population": "pop1", + "compartment_set": [ + [0, 10, 0.8], + [0, 10, 0.2], + [0, 10, 0.1], + [2, 3, 0.1], + [3, 6, 0.3] + ] + }, + "cs0": { + "population": "pop0", + "compartment_set": [] + } + })"; + + CompartmentSets sets_modified(json_modified); + + CHECK(sets_from_file != sets_modified); + CHECK_FALSE(sets_from_file == sets_modified); + } + + SECTION("Throws on missing key") { + auto sets = CompartmentSets::fromFile("./data/compartment_sets.json"); + CHECK_THROWS_AS(sets.getCompartmentSet("not_there"), std::out_of_range); + } + + SECTION("JSON serialization round-trip") { + auto sets = CompartmentSets::fromFile("./data/compartment_sets.json"); + + auto json = sets.toJSON(); + CompartmentSets roundtrip(json); + + CHECK(sets == roundtrip); + } + + SECTION("Keys returns correct vector") { + CompartmentSets sets(json); + auto keys = sets.names(); + CHECK(keys == std::vector{"cs0", "cs1"}); + } + + SECTION("GetAllCompartmentSets returns vector of CompartmentSet") { + CompartmentSets sets(json); + CHECK(sets.getAllCompartmentSets() == std::vector{cs0, cs1}); + } + + SECTION("Items returns vector of pairs (key, CompartmentSet)") { + CompartmentSets sets(json); + CHECK(sets.items() == std::vector>{{"cs0", cs0}, {"cs1", cs1}}); + } + + SECTION("Size method") { + CompartmentSets sets(json); + CHECK(sets.size() == 2); + } + + SECTION("Contains method") { + CompartmentSets sets(json); + CHECK(sets.contains("cs0")); + CHECK(sets.contains("cs1")); + CHECK_FALSE(sets.contains("missing_key")); + } + + SECTION("Invalid JSON parsings") { + // Top level must be an object + REQUIRE_THROWS_AS(CompartmentSets("1"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets("[\"array\"]"), SonataError); + + // Each CompartmentSet must be an object with 'population' and 'compartment_set' keys + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": 1 })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": "string" })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": null })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": true })"), SonataError); + + // Missing keys + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "compartment_set": [] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0" } })"), SonataError); + + // Invalid types + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": 123, "compartment_set": [] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": null, "compartment_set": [] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": "not an array" } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": 123 } })"), SonataError); + + // Invalid compartment_set elements + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [1] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [[1, 2]] } })"), SonataError); + + // Wrong types inside compartment_set elements + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [["not uint64", 0, 0.5]] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [[1, "not uint64", 0.5]] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [[1, 0, "not a number"]] } })"), SonataError); + + // Location out of bounds + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [[1, 0, -0.1]] } })"), SonataError); + REQUIRE_THROWS_AS(CompartmentSets(R"({ "cs0": { "population": "pop0", "compartment_set": [[1, 0, 1.1]] } })"), SonataError); + } + +} + diff --git a/tests/test_selection.cpp b/tests/test_selection.cpp index 118e657..5504e16 100644 --- a/tests/test_selection.cpp +++ b/tests/test_selection.cpp @@ -102,6 +102,58 @@ TEST_CASE("Selection", "[base]") { CHECK(Selection({{0, 10}}) == (even | odd)); } + SECTION("union") { + const auto empty = Selection({}); + CHECK(empty == (empty | empty)); + + // clang-format off + // 1 2 + // 01234567890123456789012345 + // a = xx xxxxx xxxxxxxxxx x + // b = xxxxx xxxxx xxxxxxxx x + // xxxxxxxxxxxxxxxxxxxxxxx x + // clang-format on + const auto a = Selection({{24, 25}, {13, 23}, {5, 10}, {0, 2}}); + const auto b = Selection({{1, 6}, {8, 13}, {15, 23}, {24, 25}}); + CHECK(b == (b | empty)); // need to use b since it's sorted + CHECK(b == (empty | b)); + + const auto expected = Selection({{0, 23}, {24, 25}}); + CHECK(expected == (a | b)); + CHECK(expected == (b | a)); + + const auto odd = Selection::fromValues({1, 3, 5, 7, 9}); + const auto even = Selection::fromValues({0, 2, 4, 6, 8}); + CHECK(Selection({{0, 10}}) == (odd | even)); + CHECK(Selection({{0, 10}}) == (even | odd)); + } + + SECTION("contains") { + const auto sel = Selection({{2, 5}, {20, 21}, {10, 15}}); // unsorted ranges + + // Inside ranges + CHECK(sel.contains(2)); + CHECK(sel.contains(3)); + CHECK(sel.contains(4)); + CHECK(sel.contains(10)); + CHECK(sel.contains(14)); + CHECK(sel.contains(20)); + + // Outside ranges + CHECK_FALSE(sel.contains(1)); + CHECK_FALSE(sel.contains(5)); // upper bound is exclusive + CHECK_FALSE(sel.contains(6)); + CHECK_FALSE(sel.contains(9)); + CHECK_FALSE(sel.contains(15)); // upper bound is exclusive + CHECK_FALSE(sel.contains(19)); + CHECK_FALSE(sel.contains(21)); // upper bound is exclusive + + // Edge case: empty selection + const auto empty = Selection({}); + CHECK_FALSE(empty.contains(0)); + CHECK_FALSE(empty.contains(100)); + } + /* need a way to test un-exported stuff SECTION("_sortAndMerge") { const auto empty = Selection::Ranges({});