Configuration attributes

An attribute can refer to a singular value of a certain type, a dict, list, reference, or to a deeper node. You can use the config.attr in node decorated classes to define your attribute:

from bsb import config

@config.node
class CandyStack:
  count = config.attr(type=int, required=True)
  candy = config.attr(type=CandyNode)
{
  "count": 12,
  "candy": {
    "name": "Hardcandy",
    "sweetness": 4.5
  }
}

Configuration dictionaries

Configuration dictionaries hold configuration nodes. If you need a dictionary of values use the types.dict syntax instead.

from bsb import config

@config.node
class CandyNode:
  name = config.attr(key=True)
  sweetness = config.attr(type=float, default=3.0)

@config.node
class Inventory:
  candies = config.dict(type=CandyStack)
{
  "candies": {
    "Lollypop": {
      "sweetness": 12.0
    },
    "Hardcandy": {
      "sweetness": 4.5
    }
  }
}

Items in configuration dictionaries can be accessed using dot notation or indexing:

inventory.candies.Lollypop == inventory.candies["Lollypop"]

Using the key keyword argument on a configuration attribute will pass the key in the dictionary to the attribute so that inventory.candies.Lollypop.name == "Lollypop".

Configuration lists

Configuration dictionaries hold unnamed collections of configuration nodes. If you need a list of values use the types.list syntax instead.

from bsb import config

@config.node
class InventoryList:
  candies = config.list(type=CandyStack)
{
  "candies": [
    {
      "count": 100,
      "candy": {
        "name": "Lollypop",
        "sweetness": 12.0
      }
    },
    {
      "count": 1200,
      "candy": {
        "name": "Hardcandy",
        "sweetness": 4.5
      }
    }
  ]
}

Configuration references

Reference attributes are ways to refer to other locations in the configuration. Upon loading the configuration, the referred value will be fetched from the referenced node:

{
  "locations": {"A": "very close", "B": "very far"},
  "where": "A"
}

Assuming here that where is a reference attribute, referring to locations, location A will be retrieved and used as the value for where. You can access references like normal configuration attributes:

>>> print(conf.where)
'very close'

Reference attributes are defined inside the configuration nodes by passing a Reference lambdas to the bsb.config.ref() function.

def my_ref_lambda(root, here):
  # This function will be called to find the location of the references
  # within the configuration. Either from the `root` of the configuration
  # or from the node containing the ref attribute (`here`)
  return here["locations"]

@config.node
class Locations:
  locations = config.dict(type=str)
  where = config.ref(my_ref_lambda)

# Or even shorter, with a true lambda:
@config.node
class Locations:
  locations = config.dict(type=str)
  where = config.ref(lambda root, here: here["locations"])

Note

Make sure that you understand what each of the reference terms correspond to:

  • where is here a reference attribute or referrer

  • my_ref_lambda is a reference lambda

  • locations is the referenced object or referee

  • 'A' is the reference key

  • 'very close' is the reference value

You can also create a reference list attribute in your node class with the bsb.config.reflist() function. Then, you should provide a list of reference keys in the configuration file:

{
  "locations": {"A": "very close", "B": "very far"},
  "where": ["A", "B"]
}
@config.node
class Locations:
    locations = config.dict(type=str)
    where = config.reflist(lambda root, here: here["locations"])

Warning

Note that reference lists are quite indestructible; setting them to None just resets them.

Many nodes of the BSB Configuration contain reference attributes. For instance, a placement node contains reference list attributes to the cell_types and partitions.

Reference lambdas

The minimal implementation of a reference lambda is a function which returns the node containing referred values starting from the configuration’s root node or the current node (here):

@config.node
class Locations:
    locations = config.dict(type=str)
    where = config.ref(lambda root, here: here["locations"])

The BSB also provides the Reference class. Through this interface, you can provide reference lambdas with more advanced behavior:

  1. The type property can be set so that the reference lambda can be used when reference_only=False.

class CellTypeReference(Reference):
    def __call__(self, root, here):
        # This function will be called to find the cell types
        # located at the root of the Configuration
        return root.cell_types

    @property
    def type(self):
        # This function will be called to cast values
        # when `reference_only=False` and should return a
        # type handler.
        from bsb import CellType

        return CellType

@config.node
class Locations:
    cell_type = config.ref(CellTypeReference())
  1. The has_ref, has_ref_value, get_ref, and get_ref_name methods can be added so that the referenced object returned from __call__ does not need to be a config node, dict, or list, but can be a customized object for advanced referencing:

class OnlyMinMaxLayerReference(Reference):
    """
    References the largest or smallest layer in the model, depending
    on whether the reference key "max" or "min" was given.
    """
    def __call__(self, root, here):
        # Filter out all the Layers into a set
        return {p for p in root.partitions if isinstance(p, Layer)}

    def has_ref(self, remote, key):
        # If there were any layers, we will have a ref
        return len(remote) and (key == "min" or key == "max")

    def has_ref_value(self, remote, value):
        # We don't want people to pass in just any Layer, they
        # have to pass in "min" or "max"
        return False

    def get_ref(self, remote, key):
        if key == "min":
            return min(remote, key=lambda l: l.volume)
        elif key == "max":
            return max(remote, key=lambda l: l.volume)

    def get_ref_name(self, remote):
        return "smallest or largest layer in {root}.partitions"

Referred object casting

On top of the Reference object, you can pass some parameters to a reference attribute to enforce the casting of the referred value:

  • ref_type: A type handler for the reference attribute. Values that can’t be found as a reference will be cast to this type. If that fails as well an error is raised.

  • reference_only: Boolean flag that when true disables casting of values, in effect enforcing that every value passed to this attribute must be found as a reference and may not be a new value not found in the referenced object. By default, it is set to True.

With reference_only set to False, you can provide either a reference or castable value:

def my_ref_object(root, here):
    return here["locations"]

@config.node
class Locations:
    locations = config.dict(type=str)
    where = config.reflist(my_ref_object, reference_only=False)
{
  "locations": {"A": "very close", "B": "very far"},
  "where": ["A", {"C": "local"}]
}
>>> print(conf.where)
['very close', 'local']

After the configuration is loaded, it is possible to either give a new reference key (usually a string) or a new reference value. In most cases, the configuration will automatically detect what you are passing into the reference:

>>> cfg.placement.general_placement.partitions.granular_layer.name
'granular_layer'
>>> cfg.placement.general_placement.partitions.granular_layer = 'molecular_layer'
>>> cfg.placement.general_placement.partitions.granular_layer.name
'molecular_layer'
>>> cfg.placement.general_placement.partitions.granular_layer = cfg.partitions.purkinje_layer
>>> cfg.placement.general_placement.partitions.granular_layer
'purkinje_layer'

As you can see, by passing the reference a string the object is fetched from the reference location, but we can also directly pass the object the reference string would point to.

Bidirectional references

The referenced node can be “notified” that it is being referenced by the populate of the reference attribute. This mechanism stores the referrer instance on the referenced node creating a bidirectional reference. During configuration references resolution, the referrer will append its instance to the list on the referee under the attribute given by the referred value (or create a new list if it doesn’t exist).

{
  "containers": {
    "A": {}
  },
  "elements": {
    "a": {"container": "A"}
  }
}
@config.node
class Container:
  name = config.attr(key=True)
  elements = config.attr(type=list, default=list, call_default=True)

def container_ref(root, here):
  return root.containers

@config.node
class Element:
  container = config.ref(container_ref, populate="elements")

This would result in cfg.containers.A.elements == [cfg.elements.a].

You can overwrite the default append or create population behavior by creating a descriptor for the referenced attribute and define a __populate__ method on it:

class PopulationAttribute:
  # Standard property-like descriptor protocol
  def __get__(self, instance, objtype=None):
    if instance is None:
      return self
    if not hasattr(instance, "_population"):
      instance._population = []
    return instance._population

  # Prevent population from being overwritten
  # Merge with new values into a unique list instead
  def __set__(self, instance, value):
    instance._population = list(set(instance._population) + set(value))

  # Example that only stores referrers if their name in the configuration is "square".
  def __populate__(self, instance, value):
    print("We're referenced in", value.get_node_name())
    if value.get_node_name().endswith(".square"):
      self.__set__(instance, [value])
    else:
      print("We only store referrers coming from a .square configuration attribute")

@config.node
class Container:
  name = config.attr(key=True)
  elements = config.attr(type=PopulationAttribute)

def container_ref(root, here):
  return root.containers

@config.node
class Element:
  container = config.ref(container_ref, populate="elements")

In the previous example, we were making sure that each value stored in the PopulationAttribute was unique by leveraging the set type. Note that you can also set the boolean flag pop_unique (True by default) of the referrer (here Element.container) and pass it as the unique_list parameter to the __populate__ method of the referee attribute.

class PopulationAttribute:
# [...]

  def __set__(self, instance, value):
    instance._population = list(value)

  def __populate__(self, instance, value, unique_list=False):
      print("We're referenced in", value.get_node_name())
    if (
        value.get_node_name().endswith(".square") and
        (not unique_list or value not in instance._population)
      ):
          instance._population.append(value)
    else:
      print("We only store referrers coming from a .square configuration attribute")

# [...]

@config.node
class Element:
  container = config.ref(container_ref, populate="elements", pop_unique=False)

Type validation

Configuration types convert given configuration values. Values incompatible with the type are rejected and the user is notified. The default type is str.

Any callable that takes 1 argument can be used as a type handler. The bsb.config.types module provides extra functionality such as validation of list and dictionaries and even more complex combinations of types. Every configuration node itself can be used as a type.

Warning

All of the members of the bsb.config.types module are factory methods: they need to be called in order to produce the type handler. Make sure that you use config.attr(type=types.any_()), as opposed to config.attr(type=types.any_).

Examples

from bsb import config, types

@config.node
class TestNode
  name = config.attr()

@config.node
class TypeNode
  # Default string
  some_string = config.attr()
  # Explicit & required string
  required_string = config.attr(type=str, required=True)
  # Float
  some_number = config.attr(type=float)
  # types.float / types.int
  bounded_float = config.attr(type=types.float(min=0.3, max=17.9))
  # Float, int or bool (attempted to cast in that order)
  combined = config.attr(type=types.or_(float, int, bool))
  # Another node
  my_node = config.attr(type=TestNode)
  # A list of floats
  list_of_numbers = config.attr(
    type=types.list(type=float)
  )
  # 3 floats
  list_of_numbers = config.attr(
    type=types.list(type=float, size=3)
  )
  # A scipy.stats distribution
  chi_distr = config.attr(type=types.distribution())
  # A python statement evaluation
  statement = config.attr(type=types.evaluation())
  # Create an np.ndarray with 3 elements out of a scalar
  expand = config.attr(
      type=types.scalar_expand(
          scalar_type=int,
          expand=lambda s: np.ones(3) * s
      )
  )
  # Create np.zeros of given shape
  zeros = config.attr(
      type=types.scalar_expand(
          scalar_type=types.list(type=int),
          expand=lambda s: np.zeros(s)
      )
  )
  # Anything
  any_ = config.attr(type=types.any_())
  # One of the following strings: "all", "some", "none"
  give_me = config.attr(type=types.in_(["all", "some", "none"]))
  # The answer to life, the universe, and everything else
  answer = config.attr(type=lambda x: 42)
  # You're either having cake or pie
  cake_or_pie = config.attr(type=lambda x: "cake" if bool(x) else "pie")

Type handlers

The TypeHandler interface in the Brain Scaffold Builder (BSB) framework allows specification of advanced type‑validation and conversion rules for configuration attributes. It shapes complex type‑handlers that require more functionality than a simple function. Type handlers are callables with optional extra attributes used by the configuration system.

__call__(self, value)

Convert the given configuration value to the desired Python type. Must raise TypeError (or subclass) when the value is invalid.

__name__(self)

Return a display name for the type‑handler. This name is used in error messages and configuration diagnostics.

__inv__(self, value)

Optional method to invert a converted value back to a representation suitable for serialization to configuration files. Configuration files should be able to be loaded and saved again without unintentional changes to the content, and this method allows complicated type handlers to create a bijective relationship between the serialized and runtime values.

Examples

A type‑handler that accepts only even integers:

from bsb import TypeHandler

class EvenIntHandler(TypeHandler):
  def __call__(self, value):
      n = int(value)
      if n % 2 != 0:
          raise TypeError(f"{value!r} is not an even integer")
      return n

  def __name__(self):
      return "even integer"

A type‑handler that converts a colour name to an RGB tuple and supports inversion:

from bsb import TypeHandler

class ColourHandler(TypeHandler):
    def __call__(self, value):
        name = str(value).lower()
        if name == "red":
            return (255, 0, 0)
        if name == "green":
            return (0, 255, 0)
        if name == "blue":
            return (0, 0, 255)
        raise TypeError(f"{value!r} is not a valid colour")

    def __inv__(self, rgb):
        if rgb == (255, 0, 0):
            return "red"
        if rgb == (0, 255, 0):
            return "green"
        if rgb == (0, 0, 255):
            return "blue"
        # fallback
        return None

    def __name__(self):
        return "colour"

Usage in a config node definition:

from bsb import config

@config.node
class MyComponent:
    favourite_colour = config.attr(type=ColourHandler(), required=True)
    even_count      = config.attr(type=EvenIntHandler(), default=0)