BerkeleyHCI / PolymorphicBlocks

A work-in-progress board-level hardware description language (HDL) providing design automation through generators and block polymorphism.
BSD 3-Clause "New" or "Revised" License
70 stars 11 forks source link

Support multipack devices #74

Closed ducky64 closed 2 years ago

ducky64 commented 2 years ago

For example, resistor arrays (which are pretty independent), multi-pack opamps (which share pins like power), multi-pack DACs (which not only share power, but may also share data).

A bunch of open design questions though:

What level of abstraction?

What representation?

Frontend

One idea is to define a block that contains the parts to be replaced. or example, using the Mcp6004:

class Mcp6004(MultipackBlock):
  def __init__(self):
    self.a = self.Block(Mcp6004Part())
    self.b = self.Block(Mcp6004Part())
    self.c = self.Block(Mcp6004Part())
    self.d = self.Block(Mcp6004Part())
    self.pwr = self.Export(self.a.pwr)  # shared power line
    self.connect(self.a.pwr, self.b.pwr, self.c.pwr, self.d.pwr)
    ...

Effectively this provides the template to pattern match on - if the parts are connected similarly, they can be replaced by the multipart block, either at the netlist or block diagram level.

Refinements can control which parts are packed together, for example each part can have a pack_refdes parameter that specifies the multipack device refdes and part, eg U1.a.

ducky64 commented 2 years ago

Random idea: Can multipack devices be a mechanism to support compositional parts too? For example, a SPI DAC can be viewed as two parts: the SPI interface, and the DAC block. Other subcircuits might want to use the DAC block alone, and multipack support can be a way to connect the interface to an abstract and generic DAC.

More practically, this might allow replacing a feedback resistive divider in a power converter with a controlled element (eg, DAC through some resistor network) that makes it a programmable converter. Though, it probably would break a bunch of the electronics modeling

Note, the other solution for this was some type of multi-domain modeling, where another domain like dataflow specifies that the analog line must be 'connected' to the microcontroller firmware. That might be cleaner, since the constraints are cleaner and perhaps conducive to more powerful automated solvers.

In Chisel, there was also the idea of cross-hierarchy operations as a compiler transform. It's a bit low-level, but similar in concept here. I believe in chip design this kind of stuff might be used for inserting BIST circuits that aren't 'conceptually' part of the 'main' logic, but need to be wired up with it.

ducky64 commented 2 years ago

This was discussed today. Short tl;dr: the best solution at the moment is to handle packing at the netlisting stage, with the EDG IR model using blocks that represent one in a pack of devices (for example, a block being 1/4 of a quad-pack ADC).

Longer recap of options:

Netlisting Option

In this option, the EDG IR model remains closer to the design intent model (with single components instead of the more optimized multipack devices). Refinement transforms the abstract blocks (eg, abstract SPI ADC, where used) into concrete blocks representing a single part (eg, the 1/4 quad-pack ADC) which model the electrical characteristics. Each block should encode parameters for that device only, or if unavailable, bulk parameters should be split evenly across the packed parts (for example, if only a total package current draw is given, each part of a quad-pack ADC would draw 1/4 of the total current draw).

However, the downside is that it's a bit artificial to model components this way. And it might not be immediately intuitive that to do a multipack device, you need to model a single part (in addition to the packing definition).

Packing itself happens at the netlister level (so EDG IR is oblivious to this optimization). The netlister would check that common connections (eg, power on each individual opamp) share the same net. Handling at the netlist level also avoids issues with 'virtual' blocks that are effectively copper, such as bridges and power merges.

The packing definition is to be defined. This could be in some custom netlist(-like?) format, or could be a block definition that specifies the packing configuration and the parts it replaces.

Packing devices may eliminate some connections, for example a quad-pack SPI ADC would not need 4 CS lines. In this case, CS for devices 2, 3, 4 are discarded (possibly with a warning or error), and the user should not-connect them in the top-level design or refinement (for pin-assignable devices like microcontrollers).

Abstraction and Replacement

(abstraction referring to being able to 'draw a box' around chunk of a hierarchy block, and put it into its own block)

In this option, the EDG compiler would replace the individual abstract blocks with a single block representing the multipack device. This keeps the EDG IR closer to the final board, and each block continues to represent a complete component (as opposed to a part of a component), but this requires rewriting parts of the design tree (and by extension, remapping all references - not conceptually hard, but still more engineering).

The main downside is that this significantly changes the design tree. The only operations current that modify the design tree are refinement (the replacement blocks still IS-A version of the original block, and supports all its capabilities, though may violate electrical constraints) and expansion. It's unclear how this would fit within that and how this would preserve guarantees on the IR structure to allow it to continue being consistent.

Otherwise, no changes to the netlister are needed.

Quick'n'dirty sketch using a multi-channel SMU as an example: image

ducky64 commented 2 years ago

This has had a complete redesign over the past week or few. Multipack devices as a netlister-only construct is going to make things very difficult, limited, and inconsistent, and so is a bad idea.

The new idea focuses on a cross-hierarchy export connection that can be set in refinements, which a separate PCB-layer multipack refinement construct generates into. image

The multipack block would be defined like any normal block (and could be instantiated like any normal block, if desired). However, they would also define a packing definition that generates the relevant refinements

Example HDL

Dual-pack (fixed length) opamp

class SomeVendorsDualPackOpamp(Block, Multipack):

  def __init__()
    # Note, structured as an application circuit (there is a inner footprint block) 
    self.ic = self.Block(…)
    self.cap = self.Block(DecouplingCapacitor(…)).connected(…)

    # note, ports have same type as opamp, opamp wrapper directly exports into this
    self.pwr1 = self.Port(VoltageSink(...))
    self.gnd1 = self.Port(VoltageSink(...)
    self.pwr2 = self.Port(Ground())
    self.gnd2 = self.Port(Ground())

    self.inn1 = self.Export(self.ic.inn1)  # type AnalogSink, parameterized by device properties
    self.inp1 = self.Export(self.ic.inp1)
    self.out1 = self.Export(self.ic.out1)  # type AnalogSource, parameterized by device properties
    self.inn2 = self.Export(self.ic.inn2)
    …

    # note: Opamp interface device itself has no parameters, fully parameterized by ports

  # This concept multipack definition provides similar syntax to block construction
  def multipack():
    # optional, if multipacking is used, this defines how the packing works, so the compiler can generate the tunnel-export and other refinements
    self.opamp1 = self.Component(Opamp())  # note: name needed for assignment
    self.packed_export(self.pwr1, self.opamp1.pwr)
    self.packed_export(self.gnd1, self.opamp1.gnd)
    self.packed_export(self.inn1, self.opamp1.inn)
    self.packed_export(self.inp1, self.opamp1.inp)
    self.packed_export(self.out1, self.opamp1.out)

    self.opamp2 = self.Component(Opamp()) 
    ...

  # A different concept for multipack, where a statically-typed function is used in the refinement - so the refinement can be type-checked; but the syntax is very different than block construction and has poor syntactic consistency
  @staticmethod
  def multipack(cls, opamp1_path: List[str], opamp2_path: List[str]) -> MultipackDevice:
    pack = MultipackDevice(cls())
    opamp1 = pack.Component(Opamp(), opamp1_path)
    pack.export(pack.device.inn1, opamp1.inn)
    pack.export(pack.device.inp1, opamp1.inp)
    opamp2 = pack.Component(Opamp(), opamp2_path)
    oack.export(pack.device.inn2, opamp2.inn)
    pack.export(pack.device.inp2, opamp2.inp)

    return pack

In board top, and with the conventional multipack definition, the refinement looks like:

  def multipacks():
    [
    (DualPackOpamp, {
      'opamp1': ['measure', 'follower', 'ic'],  # note: match on PackedDevice name
      'opamp2': ['driver', 'follower', 'ic'],
    })
    ]

The other multipack definition would have the refinement look like:

  def multipacks():
    [
    DualPackOpamp.multipack(['measure', 'follower', 'ic'], ['driver', 'follower', 'ic'],
    ]

Benefit is that it's strongly typed, but it looks very different than the other refinements.

n-pack resistor array

This is slightly different in the block is defined as a n-pack, and the generator would select an suitable part and footprint from the part table

class MultipackResistor(FootprintBlock, Multipack):
  def __init__():
    self.as = self.Port(Vector(Passive()))
    self.bs = self.Port(Vector(Passive()))
    self.resistances = self.Parameter(RangeArray())
    self.generate(self.as.allocated(), self.bs.allocated(), self.resistances)

  def generate(as_allocated: List[str], bs_allocated: List[str], resistances: List[Range]):
    # validate resistances are consistent
    # parts table code to select resistor arrays

  # This is the 'conventional' multipack definition
  # This one is simple in that the PackedDeviceArray returns a single Block object, but exports and assigns to it treat its ports as arrays - so this is somewhat poor syntactic consistency
  def multipack():
    self.resistors = self.ComponentArray(Resistor())  # returns Resistor object
    self.packed_export(self.as, self.resistors.a)  # although resistors.a is element port valued, packed_export treats it as a port-array
    self.packed_export(self.bs, self.resistors.b)
    self.packed_assign(self.resistances, self.resistors.resistance)

  # This is the explicit multipack definition - PackedDeviceArray returns a wrapper object, akin to Vector(...) for port-arrays.
  # The syntactic consistency here is better, but it's more verbose.
  def multipack():
    self.resistors = self.ComponentArray(Resistor())  # returns MultipackBlockArray object
    self.packed_export(self.as, self.resistors.ports(lambda x: x.a))  # wraps element port in port-array
    self.packed_export(self.bs, self.resistors.ports(lambda x: x.b))
    self.packed_assign(self.resistances, self.resistors.params(lambda x: x.resistance))  # wraps element param as param array

  # And this is the functional version, which while static-typing friendly has poor syntactic consistency.
  # There could also be a variation of this like directly above, where ComponentArray returns a wrapper object, which can return arrays of inner ports and parameters.
  @staticmethod
  def multipack(cls, resistors_path: List[List[str]]) -> MultipackDevice:
    pack = MultipackDevice(cls())
    resistors = pack.ComponentArray(Resistor(), resistors_path)
    pack.export(pack.device.as, resistor.a)  # array to implicit array
    pack.export(pack.device.bs, resistor.b)
    pack.assign(pack.device.resistances, resistor.resistance)

    return pack

In board top, the conventional refinement looks like:

  def multipacks():
    return [
    (MultipackResistor, {
      'resistors': [  # component dict values can be lists (paths) or list of lists (list of paths)
        ['this_block', 'res1'],
        ['this_block', 'res2'],
        ['this_block', 'res3'],
        ['this_block', 'res4'],
      ],
    ]

and the functional version refinement looks like:

  def multipacks():
    [
    MultipackResistor.multipack(['this_block', 'res1'], ['this_block', 'res2'], ['this_block', 'res3'], ['this_block', 'res4']]
    ]

TBD: refinements need a path for the multipack device

rohit507 commented 2 years ago

So, I'm in favor of the functional versions from a base readability perspective. Explicitly declaring the components to be packed (in the parameters) and having pack manage each of the cross-boundary connections separately seems like a good thing.

One issue is pack.export(pack.device.bs, resistor.b) where it's weird that pack.device.bs maps to self.bs. Would it just make more sense to rename device self? That way it's pack.export(pack.self.bs, resistors.b) and the self.bs relationship is more obvious.

Then there's the loss of names in the refinement. I'm not sure what the consequences of this would be? You could work around it by assigning to a property of pack the same way you assign to properties of self (e.g. pack.opamp1 = pack.Component(Opamp(), opamp1_path)) but that breaks the nice separation between pack and external references.

It might actually make more sense to do something like this:

  # A different concept for multipack, where a statically-typed function is used in the refinement - so the refinement can be type-checked; but the syntax is very different than block construction and has poor syntactic consistency
  @staticmethod
  def multipack(self,  opamp1_path: List[str], opamp2_path: List[str]) -> MultipackDevice:
    pack = MultipackDevice(self)
    pack.opamp1 = pack.Component(Opamp(), opamp1_path)
    pack.export(self.inn1, pack.opamp1.inn)
    pack.export(self.inp1, pack.opamp1.inp)
    pack.opamp2 = pack.Component(Opamp(), opamp2_path)
    oack.export(self.inn2, pack.opamp2.inn)
    pack.export(self.inp2, pack.opamp2.inp)

    return pack

Which requires being sneaky about the self parameter (or just making this a non-@staticmethod), but gets you the explicitness I like, maps pretty well to the names used elsewhere, and makes the pack.export more obviously directional. This version also makes it clearer that you're taking some external parts and constructing a packing that connects those external pieces to self.

Also: You definitely need a type alias from List[str] toBoardPath (or ComponentPath, PortPath, etc....) because List[BoardPath] just makes so much more sense than List[List[str]]

ducky64 commented 2 years ago

Stepping back a bit, I did a pass on the IR structure in #116. The PR currently encodes multipacking in the main design structure (board top Block) instead of in a supplemental refinements data structure.

There's a larger question about the role and structure of refinements. As currently, refinements:

Multipack kinda breaks that:

With the idea that a multipack block is a concrete item in the top block, the user-facing syntax could be a bit more consistent with the regular block definition syntax and preserve some of the niceness of the functional syntax:

class DualOpamp(..., MultipackDevice):  # this would only by the multipack shim block
  def __init__(self):
    self.ic = self.Block(…)  # this is the application circuit block, so contains the cap and the IC block inside

    self.opamp1 = self.PackedPart(Opamp())  # PackedPart and co. are methods on MultipackDevice
    self.opamp2 = self.PackedPart(Opamp())

    # alternative array syntax - as analogous to the Vector (port array) syntax as possible
    self.opamp2 = self.PackedPart(PackedArray(Opamp()))

    # this block has the same ports as the packed components
    self.pwr1 = self.PackedExport(self.opamp1.pwr)
    self.gnd1 = self.PackedExport(self.opamp1.pwr)
    self.pwr2 = self.PackedExport(self.opamp2.pwr)
    self.gnd2 = self.PackedExport(self.opamp2.pwr)
    ...

    # alternative, this could be explicit:
    self.pwr1 = self.Port(VoltageSink.empty())
    self.packed_connect(self.opamp1.pwr, self.pwr1)

class BoardTop(...):
  # This tries to more follow the design-tree syntax.
  # In theory this can go into __init__, but this seems more of a supplemental thing so is in a separate function, more like refinements
  # Because this doesn't need a monolithic multipack() call on the packed device, this may also be more composition and automation friendly?
  def multipacks():
    self.oapack = self.PackedBlock(DualOpamp())  # PackedBlock and pack are methods on BoardTop (or DesignTop); alternatively this can just be Block(...)
    self.pack(self.oapack.opamp1, ['measure', 'follower', 'ic'])
    self.pack(self.oapack.opamp2, ['measure', 'measure', 'ic'])
    # self.pack would also understand array components - where each pack() statement is one allocation

One downside here is that there's nothing to check that the packing is valid, that measure.follower.ic (in the example above) is of class Opamp, other than perhaps a check that the relevant packing ports exist. Options might be:

I think this also address naming?