Repository: ucb-bar/hammer

Issue:
Hierarchical  constraints not propagated correctly in certain circumstances
This is still hearsay at this point, but I keep having issues with the bumps API in my hierarchical design.
I will try to come up with a small example.

Hints from issue comments:
@harrisonliew: I found the following:

* Bumps constraints are not propagated to `par` in the `syn_to_par` when they are placed under `vlsi.inputs.hierarchical.constraints: [{<module>: [{vlsi.inputs.bumps..}]}]`. I believe this has something to do with the raw dicts all as independent lists underneath the module key? The workaround I have for the moment is to put bumps at the top level and skip the bumps step in leaf modules.

* `_meta` doesn't work for hierarchical module constraints. For example:
I have in `foo.yml`:
```vlsi.inputs.hierarchical:
  constraints:
  - a: [x1, x2]
  - b: [y]
```
and in `bar.yml`:
```vlsi.inputs.hierarchical:
  constraints:
  - a: [z]
```
In the `get_hierarchical_settings` method, it combines these hierarchical constraints together using `reduce(add_dicts, list_of_hier_constraints, {})`. Unfortunately, this generates:
```vlsi.inputs.hierarchical:
  constraints:
  - a: [z]
  - b: [y]
```
because `a: [z]` overwrote `a: [x1, x2]`.

* `vlsi.inputs.supplies` is present for leaf modules in the `config_db_temp.json` for the module, but is immediately followed by the default supplies (VDD, VSS).@jwright6323: @harrisonliew can you work on this?

Solution patch:
diff --git a/doc/Hammer-Use/Flowgraphs.rst b/doc/Hammer-Use/Flowgraphs.rst
index bfd0e3a3..755d27f0 100644
--- a/doc/Hammer-Use/Flowgraphs.rst
+++ b/doc/Hammer-Use/Flowgraphs.rst
@@ -10,15 +10,15 @@ They can be imported via the ``hammer.flowgraph`` module.
 Construction
 ------------
 
-Flowgraphs are nothing more than a collection of ``Node`` instances linked together via a ``Graph`` instance.
+Flowgraphs are consist of a collection of ``Node`` instances linked together via a ``Graph`` instance.
 Each ``Node`` "pulls" from a directory to feed in inputs and "pushes" output files to another directory to be used by other nodes.
-``Node`` instances are roughly equivalent to a single call to the ``hammer-vlsi`` CLI, so they take in similar attributes:
+``Node`` instances are roughly equivalent to a single call to the ``hammer-vlsi`` CLI tool, so they take in similar attributes:
 
 * The action being called
 * The tool used to perform the action
 * The pull and push directories
 * Any *required* input/output files
-* Any *optional* input/output files
+* Any *optional* input/output files (the name of the *first* output file corresponds to the name of the JSON that the action emits)
 * A driver to run the node with; this enables backwards compatibility with :ref:`hooks <hooks>`.
 * Options to specify steps within an action; this enables backwards compatibility with :ref:`flow control <flow-control>`.
 
@@ -71,7 +71,7 @@ This list can be used to instantiate a ``Graph``:
 Using the Hammer CLI tool, separate actions are manually linked via an *auxiliary* action, such as ``syn-to-par``.
 By using a flowgraph, ``Graph`` instances by default *automatically* insert auxiliary actions.
 This means that actions no longer need to be specified in a flow; the necessary nodes are inserted by the flowgraph tool.
-This feature can be disabled by setting ``auto_auxiliary=False``.
+This feature can be disabled by setting ``auto_auxiliary`` to ``False`` in a ``Graph``.
 
 A ``Graph`` can be run by calling the ``run`` method and passing in a starting node.
 When running a flow, each ``Node`` keeps an internal status based on the status of the action being run:
@@ -82,7 +82,7 @@ When running a flow, each ``Node`` keeps an internal status based on the status
 * ``INCOMPLETE``: The action ran into an error while being run.
 * ``INVALID``: The action's outputs have been invalidated (e.g. inputs or attributes have changed).
 
-The interactions between the statuses are described in the diagram:
+The interactions between the statuses are described in the following state diagram:
 
 .. mermaid::
 
@@ -155,3 +155,11 @@ Which would render like this:
         syn_to_par --> par
 
 Note that the separators have been changed to comply with Mermaid syntax.
+
+Caveats
+-------
+
+The flowgraph frontend has a number of caveats that prevent it from full parity with the current CLI tool:
+
+* If flows are constructed in a Python interactive session, then errors from the underlying tool do *not* propagate to the flowgraph and thus render the interactive session unusable.
+  This can be worked around by embedding the flow in a Python script and running it from the command line.
diff --git a/doc/Hammer-Use/Hammer-APIs.rst b/doc/Hammer-Use/Hammer-APIs.rst
index 78be4bf7..16a768e7 100644
--- a/doc/Hammer-Use/Hammer-APIs.rst
+++ b/doc/Hammer-Use/Hammer-APIs.rst
@@ -87,6 +87,12 @@ For track spacing > 0 and effective power utilization < 100%, powerstraps are ro
 
 The currently supported API supports power strap generation by tracks, which auto-calculates power strap width, spacing, set-to-set distance, and offsets based on basic DRC rules specified in the technology Stackup object. The basic pieces of information needed are the desired track utilization per strap and overall power strap density. Different values can be specified on a layer-by-layer basis by appending ``_<layer name>`` to the end of the desired option.
 
+For mesh pattern, the following diagram shows how the sizings of a mesh-patterned strap is derived from user inputs.
+
+.. image:: mesh_no_space.png
+
+Notice that for a mesh pattern, users do not need to specify the layer track spacing, since that should not matter for a regular mesh pattern. 
+
 Special Cells
 -------------
 Special cells are specified in the technology's JSON, but are exposed to provide lists of cells needed for certain steps, such as for fill, well taps, and more. Synthesis and place-and-route tool plugins can grab the appropriate type of special cell for the relevant steps.
diff --git a/doc/Hammer-Use/Hierarchical.rst b/doc/Hammer-Use/Hierarchical.rst
index ae026396..d81fe10d 100644
--- a/doc/Hammer-Use/Hierarchical.rst
+++ b/doc/Hammer-Use/Hierarchical.rst
@@ -11,7 +11,7 @@ Hierarchical Hammer Config
 The hierarchal flow is controlled in the ``vlsi.inputs.hierarchal`` namespace. To specify hierarchical mode, you must specify the following keys. In this example, we have our top module set as ``ChipTop``, with a submodule ``ModuleA`` and another submdule ``ModuleAA`` below that (these are names of Verilog modules).
 
 .. code-block:: yaml
-    
+
     vlsi.inputs.hierarchical:
       mode: hierarchical
       top_module: ChipTop
@@ -23,42 +23,31 @@ The hierarchal flow is controlled in the ``vlsi.inputs.hierarchal`` namespace. T
         - ModuleAA
       constraints:
       - ChipTop:
-        - vlsi.core...
-        - vlsi.inputs...
+          vlsi.core...
+          vlsi.inputs...
       - ModuleA:
-        - vlsi.core...
-        - vlsi.inputs...
+          vlsi.core...
+          vlsi.inputs...
       - ModuleAA:
-        - vlsi.core...
-        - vlsi.inputs...
+          vlsi.core...
+          vlsi.inputs...
 
-Note how the configuration specific to each module in ``vlsi.inputs.hierarchical.constraints`` are list items, whereas in a flat flow, they would be at the root level.
+Note how the configuration specific to each module in ``vlsi.inputs.hierarchical.constraints`` appears like they would in a flat flow. This means you can apply meta directives to them as normal, as long as the references are in the same parent dictionary (i.e., key = module name).
+If you have constraints for the same module in multiple files, you can use ``vlsi.inputs.hierarchical.constraints_meta: append`` and the constraints will be combined properly.
 
-Placement constraints for each module, however, are not specified here. Instead, they should be specified in ``vlsi.inputs.hierarchical.manual_placement_constraints``. The parameters such as ``x``, ``y``, ``width``, ``height``, etc. are omitted from each constraint for clarity. In the bottom-up hierarchal flow, instances of submodules are of ``type: hardmacro`` because they are hardened from below.
+.. note::
+    In a bottom-up hierarchical flow, submodule instances must have ``type: hardmacro`` in ``vlsi.inputs.placement_constraints`` because they are hardened from below.
 
-.. code-block:: yaml
+Special considerations for legacy support:
 
-    vlsi.inputs.hierarchical:
-      manual_placement_constraints_meta: append
-      manual_placement_constraints:
-      - ChipTop:
-        - path: "ChipTop"
-          type: toplevel
-        - path: "ChipTop/path/to/instance/of/ModuleA"
-          type: hardmacro
-      - ModuleA:
-        - path: "ModuleA"
-          type: toplevel
-        - path: "ModuleA/path/to/instance/of/ModuleAA"
-          type: hardmacro
-      - ModuleAA:
-        - path: "moduleAA"
-          type: toplevel
+* If each module's ``constraints`` is a list of dicts with a single key/value pair, meta actions are not supported and the entire project configuration must be specified in a single file.
+
+* If placement constraints are specified with ``vlsi.inputs.hierarchical.manual_placement_constraints``, all of a given module's placement constraints must be specified in a single file.
 
 Flow Management and Actions
 ---------------------------
 
-Based on the structure in ``vlsi.inputs.hierarchical.manual_modules``, Hammer constructs a hierarchical flow graph of dependencies. In this particular example, synthesis and place-and-route of ``ModuleAA`` will happen first. Synthesis of ``ModuleA`` will then depend on the place-and-route output of ``ModuleAA``, and so forth. 
+Based on the structure in ``vlsi.inputs.hierarchical.manual_modules``, Hammer constructs a hierarchical flow graph of dependencies. In this particular example, synthesis and place-and-route of ``ModuleAA`` will happen first. Synthesis of ``ModuleA`` will then depend on the place-and-route output of ``ModuleAA``, and so forth.
 
 These are enumerated in the auto-generated Makefile, ``hammer.d``, which is placed in the directory pointed to by the ``--obj_dir`` command line flag when the ``buildfile`` action is run. This action must be run BEFORE executing your flow. If you adjust the hierarchy, you must re-run this action.
 
@@ -93,15 +82,7 @@ In a bottom-up hierarchical flow, is is important to remember that submodules do
 Special Notes & Limitations
 ---------------------------
 
-#. Hammer IR keys propagate up through the hierarchical tree. For example, if ``vlsi.inputs.clocks`` was specified in the constraints for ``ModuleAA`` but not for ``ModuleA``, ``ModuleA`` will inherit ``ModuleAA``'s constraints. Take special care of where your constraints come from, especially for a parent module with more than one submodule. To avoid confusion, it is recommended to specify the same set of keys for every module.
-
-#. Hammer IR keys specified at the root level (i.e. outside of ``vlsi.inputs.hierarchical.constraints``) do not override the corresponding submodule constraints. However, if you add a Hammer IR file using ``-p`` on the command line (after the file containing ``vlsi.inputs.hierarchical.constraints``), those keys are global and override submodule constraints unless a meta action is specified. To avoid confusion, it is recommended to specify all constraints with ``vlsi.inputs.hierarchical.constraints``.
-
-#. Due to the structure of ``vlsi.inputs.hierarchical.constraints`` as a list structure, currently, there are the following limitations:
-
-    * You must include all of the constraints in a single file. The config parser is unable to combine constraints from differnt files because most meta actions do not work on list items (advanced users will need to use ``deepsubst``). This will make it harder for collaboration, and unfortunately, changes to module constraints at a higher level of hierarchy after submodules are hardened will trigger the Make dependencies, so you will need to modify the generated Makefile or use redo-targets.
-
-    * Other issues have been observed, such as the bump API failing (see `this issue <https://github.com/ucb-bar/hammer/issues/401>`_ at the top module level. This is caused by similar mechanisms as above. The workaround is to ensure that bumps are specified at the root level for only the top module and the bumps step is removed from submodule par actions.
+#. Hammer IR keys specified at the root level (i.e. outside of ``vlsi.inputs.hierarchical.constraints``) are overridden by the corresponding submodule constraints. Generally, to avoid confusion, it is recommended to specify all constraints module-by-module with ``vlsi.inputs.hierarchical.constraints``.
 
 #. Most Hammer APIs are not yet intelligent enough to constrain across hierarchical boundaries. For example:
 
diff --git a/e2e/configs-design/pass/mock_hier.yml b/e2e/configs-design/pass/mock_hier.yml
index 40ef0269..96e38184 100644
--- a/e2e/configs-design/pass/mock_hier.yml
+++ b/e2e/configs-design/pass/mock_hier.yml
@@ -21,13 +21,36 @@ vlsi.inputs.hierarchical:
         - SubModD
     - SubModB:
         - SubModE
-  manual_placement_constraints:
-    - ChipTop: []
-    - SubModA: []
-    - SubModB: []
-    - SubModC: []
-    - SubModD: []
-    - SubModE: []
+  constraints:
+    - ChipTop:
+        vlsi.inputs.placement_constraints:
+        - path: ChipTop
+          type: toplevel
+          x: 0
+          y: 0
+          width: 1000
+          height: 1000
+          margins:
+            left: 0
+            right: 0
+            top: 0
+            bottom: 0
+    - SubModC:
+        vlsi.inputs.placement_constraints:
+        - path: SubModC
+          type: toplevel
+          x: 0
+          y: 0
+          width: 100
+          height: 100
+          margins:
+            left: 0
+            right: 0
+            top: 0
+            bottom: 0
+        vlsi.inputs.power_spec_type: manual
+        vlsi.inputs.power_spec_contents: fake_command
+        par.generate_power_straps_options.by_tracks.strap_layers: [m3, m4, m5]
 
 vlsi.core.synthesis_tool: "hammer.synthesis.mocksynth"
 synthesis.mocksynth.temp_folder: "obj_dir"
diff --git a/e2e/configs-design/pass/mock_hier_addl.yml b/e2e/configs-design/pass/mock_hier_addl.yml
new file mode 100644
index 00000000..1a38c462
--- /dev/null
+++ b/e2e/configs-design/pass/mock_hier_addl.yml
@@ -0,0 +1,6 @@
+vlsi.inputs.hierarchical.constraints_meta: append
+vlsi.inputs.hierarchical.constraints:
+  - SubModC:
+      vlsi.inputs.power_spec_type: upf
+      par.generate_power_straps_options.by_tracks.strap_layers: [m6]
+      par.generate_power_straps_options.by_tracks.strap_layers_meta: append
\ No newline at end of file
diff --git a/e2e/configs-design/pass/mock_hier_legacy.yml b/e2e/configs-design/pass/mock_hier_legacy.yml
new file mode 100644
index 00000000..914fc9b7
--- /dev/null
+++ b/e2e/configs-design/pass/mock_hier_legacy.yml
@@ -0,0 +1,53 @@
+# Generate Make include to aid in flow
+vlsi.core.build_system: make
+
+vlsi.inputs.power_spec_type: "cpf"
+vlsi.inputs.power_spec_mode: "auto"
+
+synthesis.inputs:
+  top_module: "pass"
+  input_files: ["src/pass.v"]
+
+vlsi.inputs.hierarchical:
+  mode: hierarchical
+  top_module: ChipTop
+  config_source: manual
+  manual_modules:
+    - ChipTop:
+        - SubModA
+        - SubModB
+    - SubModA:
+        - SubModC
+        - SubModD
+    - SubModB:
+        - SubModE
+  manual_placement_constraints:
+    - ChipTop: []
+    - SubModA: []
+    - SubModB: []
+    - SubModC:
+      - path: SubModC
+        type: toplevel
+        x: 0
+        y: 0
+        width: 100
+        height: 100
+        margins:
+          left: 0
+          right: 0
+          top: 0
+          bottom: 0
+    - SubModD: []
+    - SubModE: []
+  constraints:
+    - SubModC:
+        vlsi.inputs.power_spec_type: manual
+        vlsi.inputs.power_spec_contents: fake_command
+        par.generate_power_straps_options.by_tracks.strap_layers: [m3, m4, m5]
+
+vlsi.core.synthesis_tool: "hammer.synthesis.mocksynth"
+synthesis.mocksynth.temp_folder: "obj_dir"
+vlsi.core.par_tool: "hammer.par.mockpar"
+vlsi.core.sim_tool: "hammer.sim.mocksim"
+vlsi.core.drc_tool: "hammer.drc.mockdrc"
+vlsi.core.lvs_tool: "hammer.lvs.mocklvs"
diff --git a/hammer/config/config_src.py b/hammer/config/config_src.py
index 80493940..4d92a6b0 100644
--- a/hammer/config/config_src.py
+++ b/hammer/config/config_src.py
@@ -790,9 +790,9 @@ class HammerDatabase:
         return [self._runtime]
 
     @staticmethod
-    def internal_keys() -> Set[str]:
+    def internal_keys() -> List[str]:
         """Internal keys that shouldn't show up in any final config."""
-        return {_CONFIG_PATH_KEY, _NEXT_FREE_INDEX_KEY}
+        return [_CONFIG_PATH_KEY, _NEXT_FREE_INDEX_KEY]
 
     def get_config(self) -> dict:
         """
diff --git a/hammer/config/defaults.yml b/hammer/config/defaults.yml
index f0c86151..59f41acf 100644
--- a/hammer/config/defaults.yml
+++ b/hammer/config/defaults.yml
@@ -60,13 +60,13 @@ vlsi.technology:
   bump_block_cut_layer: null # Top cut/via layer for blockage under bumps. (Optional[str])
   # Only used if using vlsi.inputs.bumps
   # TODO: remove this after stackup supports vias ucb-bar/hammer#354
-  
+
   tap_cell_interval: 10.0 # Spacing between columns of tap cells (float)
   # Must be overridden by technology plugin defaults or else you will have DRC/latch-up errors.
 
   tap_cell_offset: 0.0 # Offset of the first column of tape cells from the edge of the floorplan (float)
   # Should be overridden by technology plugin defaults.
-  
+
   routing_layers: null # If specified, set/override the [bottom, top] layers used for routing. (Optional[Tuple[int, int]])
   # Both must match the index number (not name) of layers used in the stackup.
 
@@ -103,7 +103,7 @@ vlsi.technology:
 
   timing_lib_pref: "NLDM"
   # Select a timing lib preference, available options include:
-  # NLDM, ECSM, and CCS (lower or upper case acceptable). 
+  # NLDM, ECSM, and CCS (lower or upper case acceptable).
   # If no preference is specified, then the following preference order is followed:
   # NLDM -> ECSM -> CCS
 
@@ -145,7 +145,7 @@ vlsi.inputs:
     manual_modules: [] # Manual hierarchical definitions used only if hierarchical_definition_source is set to manual mode.
     # Should be a list along the lines of [{"module1": "module1_sub1", "module1_sub2", "module2": "module2_sub"}].
 
-    manual_placement_constraints: [] # Manual hierarchical placement constraints used only if hierarchical_definition_source is set to manual mode.
+    manual_placement_constraints: [] # (Deprecated) Manual hierarchical placement constraints used only if hierarchical_definition_source is set to manual mode.
     # Should be a list along the lines of [{"module1": <list of PlacementConstraint structs>}].
 
     constraints: [] # Manual hierarchical constraints. Overrides generic constraints on a per module basis.
@@ -212,7 +212,7 @@ vlsi.inputs:
   custom_sdc_constraints: [] # List of custom sdc constraints to use. (List[str])
   # These are appended after all other generated constraints (clock, pin, delay, load, etc.).
   # IMPORTANT: Since SDC is unitless, you must manually verify that your time & cap units match the tech library's units.
-  
+
   custom_sdc_files: [] # List of custom sdc files to append. (List[str])
   # These are appended after all other generated constraints and custom_sdc_constraints.
 
@@ -328,7 +328,7 @@ vlsi.inputs:
     # type: float
     pitch: 0.0
     global_x_offset: 0.0 # offset the bump map in the x-axis (float)
-    global_y_offset: 0.0 # offset the bump map in the y-axis (float)    
+    global_y_offset: 0.0 # offset the bump map in the y-axis (float)
     cell: "" # cell (str) - Name of the default bump cell
     assignments: [] # assignments - List of BumpAssignment structs. You must specify one of name or no_connect.
     # If both are specified the bump will be left unconnected
@@ -632,6 +632,9 @@ par:
       power_nets: [] # List of power nets to generate straps for. If empty, generates straps for all nets in vlsi.inputs.supplies
       # type: List[str]
 
+      pattern: "coupled" # existing default powerstrap pattern (e.g. ----VDD-VSS----VDD-VSS----VDD-VSS----)
+
+
 # DRC settings
 drc.inputs:
 # DRC settings
@@ -898,7 +901,7 @@ power.inputs:
   #       "write_profile" - profiles all power types on all categories for all the sub-hierarchies for a given design instance (*.fsdb)
   #       "profile" - run plot_profile + dump_profile
   #       "all" - generate all of the above report formats
-  
+
   # examples:
   #   report_configs: [{waveform_path: "/path/to/fsdb", module: "chiptop", levels:3, start_time: "0ns", end_time: "1ns", toggle_signal:"/ChipTop/clock", num_toggles:1, frame_count:1000, report_name: "my_fsdb_report"}]
   #   report_configs: [{waveform_path: "/path/to/fsdb", inst: "ChipTop/system/tile_prci_domain/tile_reset_domain_tile", interval_size: "1ns", output_formats: ["plot_profile"]}]
diff --git a/hammer/config/defaults_types.yml b/hammer/config/defaults_types.yml
index a2ad9c90..f500f292 100644
--- a/hammer/config/defaults_types.yml
+++ b/hammer/config/defaults_types.yml
@@ -58,7 +58,7 @@ vlsi.technology:
 
   # Offset of the first column of tape cells from the edge of the floorplan
   # type: float
-  tap_cell_offset: float 
+  tap_cell_offset: float
 
   # Set the [bottom, top] layer used for routing. (Optional[Tuple[int, int]])
   routing_layers: Optional[list[int]]
@@ -105,7 +105,7 @@ vlsi.inputs:
     manual_placement_constraints: list[dict[str, list]]
 
     # Manual hierarchical constraints. Overrides generic constraints on a per module basis.
-    constraints: list[dict[str, list]]
+    constraints: list[dict[str, Any]]
 
   # ILMs for hierarchical mode.
   ilms: list[dict[str, str]]
@@ -160,9 +160,9 @@ vlsi.inputs:
     y: int
     # pitch (float) - pitch of bumps in microns
     pitch: float
-    # global_x_offset (float) - offset the bump map in the x-axis 
+    # global_x_offset (float) - offset the bump map in the x-axis
     global_x_offset: float
-    # global_y_offset (float) - offset the bump map in the y-axis 
+    # global_y_offset (float) - offset the bump map in the y-axis
     global_y_offset: float
     # cell (str) - Name of the default bump cell
     cell: str
@@ -283,7 +283,7 @@ par:
   # Spacing around blocks and hardmacros in microns
   blockage_spacing: float
 
-  # Ratio between the power blockage + place halo relative to route blockage. Route blockage should be smaller. 
+  # Ratio between the power blockage + place halo relative to route blockage. Route blockage should be smaller.
   power_to_route_blockage_ratio: float
 
   # Top metal layer that is pulled back around blocks and hardmacros
@@ -332,6 +332,7 @@ par:
       # type: list[str]
       strap_layers: list[str]
 
+
       # Indicates if Hammer should generate straps for the stdcell rail layer
       # type: bool
       generate_rail_layer: bool
@@ -344,6 +345,11 @@ par:
       # type: list[str]
       power_nets: list[str]
 
+      # Powerstrap patterns: e.g. "coupled" or "mesh"
+      # type: str
+      pattern: str
+
+
 # DRC settings
 drc.inputs:
   # Top RTL module.
diff --git a/hammer/flowgraph/flowgraph.py b/hammer/flowgraph/flowgraph.py
index 7d679d63..30b087c7 100644
--- a/hammer/flowgraph/flowgraph.py
+++ b/hammer/flowgraph/flowgraph.py
@@ -10,21 +10,18 @@
 
 import json
 import os
-import uuid
 from dataclasses import dataclass, field, asdict
 from enum import Enum
 from typing import Any, Union
 
 import networkx as nx
-from networkx.readwrite import json_graph
 
 from hammer.logging import HammerVLSILogging
 from hammer.vlsi.cli_driver import CLIDriver
 
 
 class Status(Enum):
-    """Represents the status of a node in the flowgraph.
-    """
+    """Represents the status of a node in the flowgraph."""
     NOT_RUN    = "NOT_RUN"
     RUNNING    = "RUNNING"
     INCOMPLETE = "INCOMPLETE"
@@ -39,18 +36,17 @@ class Node:
     Returns:
         Node: Complete description of an action.
     """
-    action:            str
-    tool:              str
-    pull_dir:          str
-    push_dir:          str
-    required_inputs:   list[str]
-    required_outputs:  list[str]
-    status:            Status    = Status.NOT_RUN
-    # __uuid:            uuid.UUID = field(default_factory=uuid.uuid4)
-    driver:            CLIDriver = field(default_factory=CLIDriver)
-    optional_inputs:   list[str] = field(default_factory=list)
-    optional_outputs:  list[str] = field(default_factory=list)
-    step_controls:     dict[str, str] = field(default_factory=lambda: {
+    action:           str
+    tool:             str
+    pull_dir:         str
+    push_dir:         str
+    required_inputs:  list[str]
+    required_outputs: list[str]
+    status:           Status    = Status.NOT_RUN
+    driver:           CLIDriver = field(default_factory=CLIDriver)
+    optional_inputs:  list[str] = field(default_factory=list)
+    optional_outputs: list[str] = field(default_factory=list)
+    step_controls:    dict[str, str] = field(default_factory=lambda: {
         "start_before_step": "",
         "start_after_step": "",
         "stop_before_step": "",
@@ -204,7 +200,7 @@ class Graph:
                         parent.push_dir,
                         child.pull_dir,
                         parent.required_outputs,
-                        [f"{aux_action}-out.json"],
+                        child.required_inputs,
                     )
                     changes.append((parent_idx, child_idx, aux_node))
 
@@ -213,7 +209,7 @@ class Graph:
             parent, children = list(edge_list_copy.items())[parent_idx]
 
             child = children[child_idx]
-            child.required_inputs = aux_node.required_outputs
+            child.required_inputs.extend(aux_node.required_outputs)
 
             children[child_idx] = aux_node
             if aux_node not in edge_list_copy:
@@ -299,13 +295,6 @@ class Graph:
             ctxt.fatal(f"Step {node.action} failed")
         return code
 
-    def to_json(self) -> dict:
-        """Encodes a graph as a JSON string.
-
-        Returns:
-            str: JSON dump of a flowgraph.
-        """
-        return json_graph.node_link_data(self.networkx)
 
     def to_mermaid(self) -> str:
         """Converts the flowgraph into Mermaid format for visualization.
diff --git a/hammer/par/innovus/__init__.py b/hammer/par/innovus/__init__.py
index 0c1b9559..da50ef50 100644
--- a/hammer/par/innovus/__init__.py
+++ b/hammer/par/innovus/__init__.py
@@ -108,7 +108,7 @@ class Innovus(HammerPlaceAndRouteTool, CadenceTool):
 
     @property
     def output_gds_filename(self) -> str:
-        return os.path.join(self.run_dir, "{top}.gds".format(top=self.top_module))
+        return os.path.join(self.run_dir, "{top}.gds.gz".format(top=self.top_module))
 
     @property
     def output_netlist_filename(self) -> str:
diff --git a/hammer/vlsi/cli_driver.py b/hammer/vlsi/cli_driver.py
index 61430267..57491486 100644
--- a/hammer/vlsi/cli_driver.py
+++ b/hammer/vlsi/cli_driver.py
@@ -456,31 +456,6 @@ class CLIDriver:
         return self.create_action("par", hooks if len(hooks) > 0 else None,
                                   pre_action_func, post_load_func, post_run_func)
 
-    @staticmethod
-    def get_full_config(driver: HammerDriver, output: dict) -> dict:
-        """
-        Get the full configuration by combining the project config from the
-        driver with the given output dict (i.e. it contains only
-        "synthesis.output.blah") that we want to combine with the project
-        config.
-        :param driver: HammerDriver that has the full project config.
-        :param output: Output dict containing specific settings we want to add
-                       to the full project config.
-        :return: Full project config combined with the output dict
-        """
-        if "vlsi.builtins.is_complete" in output:
-            if bool(output["vlsi.builtins.is_complete"]):
-                raise ValueError("Output-only config claims it is complete")
-        else:
-            raise ValueError("Output-only config does not appear to be output only")
-
-        output_full = deepdict(driver.project_config)
-        output_full.update(deepdict(output))
-        # Merged configs are always complete
-        if "vlsi.builtins.is_complete" in output_full:
-            del output_full["vlsi.builtins.is_complete"]
-        return output_full
-
     def create_drc_action(self, custom_hooks: List[HammerToolHookAction],
                           pre_action_func: Optional[Callable[[HammerDriver], None]] = None,
                           post_load_func: Optional[Callable[[HammerDriver], None]] = None,
@@ -597,13 +572,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Synthesis tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.syn_tool.run_dir, "syn-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.syn_tool.run_dir, "syn-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.syn_tool.run_dir, "syn-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "par":
                 if not driver.load_par_tool(get_or_else(self.par_rundir, "")):
                     return None
@@ -617,13 +592,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Place-and-route tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.par_tool.run_dir, "par-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.par_tool.run_dir, "par-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.par_tool.run_dir, "par-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "drc":
                 if not driver.load_drc_tool(get_or_else(self.drc_rundir, "")):
                     return None
@@ -637,13 +612,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("DRC tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.drc_tool.run_dir, "drc-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.drc_tool.run_dir, "drc-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.drc_tool.run_dir, "drc-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "lvs":
                 if not driver.load_lvs_tool(get_or_else(self.lvs_rundir, "")):
                     return None
@@ -657,13 +632,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("LVS tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.lvs_tool.run_dir, "lvs-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.lvs_tool.run_dir, "lvs-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.lvs_tool.run_dir, "lvs-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "sram_generator":
                 if not driver.load_sram_generator_tool(get_or_else(self.sram_generator_rundir, "")):
                     return None
@@ -691,13 +666,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Sim tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.sim_tool.run_dir, "sim-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.sim_tool.run_dir, "sim-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.sim_tool.run_dir, "sim-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "power":
                 if not driver.load_power_tool(get_or_else(self.power_rundir, "")):
                     return None
@@ -711,13 +686,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Power tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.power_tool.run_dir, "power-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.power_tool.run_dir, "power-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.power_tool.run_dir, "power-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "formal":
                 if not driver.load_formal_tool(get_or_else(self.formal_rundir, "")):
                     return None
@@ -731,13 +706,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Formal tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.formal_tool.run_dir, "formal-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.formal_tool.run_dir, "formal-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.formal_tool.run_dir, "formal-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             elif action_type == "timing":
                 if not driver.load_timing_tool(get_or_else(self.timing_rundir, "")):
                     return None
@@ -751,10 +726,10 @@ class CLIDriver:
                 if not success:
                     driver.log.error("Timing tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.timing_tool.run_dir, "timing-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.timing_tool.run_dir, "timing-output-full.json"),
                                          self.get_full_config(driver, output))
-                post_run_func_checked(driver)
             elif action_type == "pcb":
                 if not driver.load_pcb_tool(get_or_else(self.pcb_rundir, "")):
                     return None
@@ -768,13 +743,13 @@ class CLIDriver:
                 if not success:
                     driver.log.error("PCB deliverable tool did not succeed")
                     return None
+                post_run_func_checked(driver)
                 dump_config_to_json_file(os.path.join(driver.pcb_tool.run_dir, "pcb-output.json"), output)
                 dump_config_to_json_file(os.path.join(driver.pcb_tool.run_dir, "pcb-output-full.json"),
                                          self.get_full_config(driver, output))
                 if driver.dump_history:
                     dump_config_to_yaml_file(os.path.join(driver.pcb_tool.run_dir, "pcb-output-history.yml"),
                                             add_key_history(self.get_full_config(driver, output), key_history))
-                post_run_func_checked(driver)
             else:
                 raise ValueError("Invalid action_type = " + str(action_type))
             # TODO: detect errors
@@ -1263,6 +1238,31 @@ class CLIDriver:
         """Get the list of valid actions for the command-line driver."""
         return list(self.action_map().keys())
 
+    @staticmethod
+    def get_full_config(driver: HammerDriver, output: dict) -> dict:
+        """
+        Get the full configuration by combining the project config from the
+        driver with the given output dict (i.e. it contains only
+        "synthesis.output.blah") that we want to combine with the project
+        config.
+        :param driver: HammerDriver that has the full project config.
+        :param output: Output dict containing specific settings we want to add
+                       to the full project config.
+        :return: Full project config combined with the output dict
+        """
+        if "vlsi.builtins.is_complete" in output:
+            if bool(output["vlsi.builtins.is_complete"]):
+                raise ValueError("Output-only config claims it is complete")
+        else:
+            raise ValueError("Output-only config does not appear to be output only")
+
+        output_full = deepdict(driver.project_config)
+        output_full.update(deepdict(output))
+        # Merged configs are always complete
+        if "vlsi.builtins.is_complete" in output_full:
+            del output_full["vlsi.builtins.is_complete"]
+        return output_full
+
     def args_to_driver(self, args: dict,
                        default_options: Optional[HammerDriverOptions] = None) -> \
             Tuple[HammerDriver, List[str]]:
@@ -1424,6 +1424,8 @@ class CLIDriver:
                         module=module))  # TODO(edwardw): fix this ugly os.path.join; it doesn't belong here.
                     # TODO(edwardw): remove ugly hack to store stuff in parent context
                     base_project_config[0] = deeplist(driver.project_configs)
+                    print(base_project_config[0])
+                    print(config)
                     d.update_project_configs(deeplist(base_project_config[0]) + [config])
 
                 def par_pre_func(d: HammerDriver) -> None:
@@ -1483,7 +1485,7 @@ class CLIDriver:
                     with open(os.path.join(rundir, "module_config.json"), "w") as f:
                         new_output_json = json.dumps(config, cls=HammerJSONEncoder, indent=4)
                         f.write(new_output_json)
-
+                    # Restore the original project config to remove hierarchical settings.
                     d.update_project_configs(deeplist(base_project_config[0]))
 
                 def syn_post_run(d: HammerDriver) -> None:
diff --git a/hammer/vlsi/driver.py b/hammer/vlsi/driver.py
index 8b2862bd..eedc9af9 100644
--- a/hammer/vlsi/driver.py
+++ b/hammer/vlsi/driver.py
@@ -65,7 +65,7 @@ class HammerDriver:
         file_logger = HammerVLSIFileLogger(options.log_file)
         HammerVLSILogging.add_callback(file_logger.callback)
         self.log = HammerVLSILogging.context()  # type: HammerVLSILoggingContext
-        
+
         # Create a new hammer database.
         self.database = hammer_config.HammerDatabase()  # type: hammer_config.HammerDatabase
 
@@ -1670,8 +1670,8 @@ class HammerDriver:
         hier_source_key = "vlsi.inputs.hierarchical.config_source"
         hier_source = str(self.database.get_setting(hier_source_key))
         hier_modules = {}  # type: Dict[str, List[str]]
+        hier_constraints = {}  # type: Dict[str, Any]
         hier_placement_constraints = {}  # type: Dict[str, List[PlacementConstraint]]
-        hier_constraints = {}  # type: Dict[str, List[Dict]]
 
         # This is retrieving the list of hard macro sizes to be used when creating PlacementConstraint tuples later
         list_of_hard_macros = self.database.get_setting("vlsi.technology.extra_macro_sizes")  # type: List[Dict]
@@ -1686,56 +1686,83 @@ class HammerDriver:
             if len(list_of_hier_modules) == 0:
                 raise ValueError("No hierarchical modules defined manually in manual hierarchical mode")
             hier_modules = reduce(add_dicts, list_of_hier_modules)
+            all_modules = list(hier_modules.keys()) + reduce_list_str(add_lists, hier_modules.values())
+            in_place_unique(all_modules)
 
-            list_of_placement_constraints = self.database.get_setting(
-                "vlsi.inputs.hierarchical.manual_placement_constraints")  # type: List[Dict]
-            assert isinstance(list_of_placement_constraints, list)
-            combined_raw_placement_dict = reduce(add_dicts, list_of_placement_constraints, {})  # type: Dict[str, List[Dict[str, Any]]]
-
-            # This helper function filters only the dict containing the toplevel placement constraint, if any, from the provided list of dicts.
-            # If the list does not contain a toplevel constraint, it returns None.
-            def get_toplevel(d: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
-                results = list(filter(lambda x: x["type"] == "toplevel", d))
-                if len(results) == 0:
-                    return None
-                else:
-                    return results[-1]
-
-            # Use the above helper method to filter down the combined raw placement dict into a dict:
-            # - keys are hierarchical module name
-            # - values are dicts containing toplevel constraints or None
-            toplevels_opt = {k: get_toplevel(v) for k, v in combined_raw_placement_dict.items()}  # type: Dict[str, Optional[Dict[str, Any]]]
-            # This filters out all of the Nones to get only hierarchical modules with toplevel placement constraints
-            toplevels = {k: v for k, v in toplevels_opt.items() if v is not None}  # type: Dict[str, Dict[str, Any]]
-            # This converts each dict entry into a MacroSize tuple, which should now represent all hierarchical modules
-            hier_macros = [MacroSize(library="", name=x[0], width=x[1]["width"], height=x[1]["height"]) for x in toplevels.items()]
-            masters = hard_macros + hier_macros
-
-            hier_placement_constraints = {key: list(map(partial(PlacementConstraint.from_masters_and_dict, masters), lst))
-                                          for key, lst in combined_raw_placement_dict.items()}
             # Iterate over project configs to find which ones contain hierarchical constraints
             # For each file that does append its path to the special key in the extracted
             # hierarchical constraints section.
-            """
             hier_constraint_source = ""  # type: str
+            [config_path_key, _] = self.database.internal_keys()
             for project_conf in self.project_configs:
-                if "vlsi.inputs.hierarchical.constraints" in project_conf:
-                    hier_constraint_source = project_conf[hammer_config._CONFIG_PATH_KEY]
+                try:
+                    hier_constraint_source = project_conf[config_path_key]
                     pc = project_conf["vlsi.inputs.hierarchical.constraints"]  # type: List[Dict]
                     # Add CONFIG_PATH_KEY to actual project configs for each project config's hierarchical constraint
                     # keys then update project configs at the end
                     for md in pc:
-                        md  # one entry dict with a list
-                        for m in md.keys():
-                            if hammer_config._CONFIG_PATH_KEY in md[m][-1]:
+                        md  # one entry dict with a list or dict
+                        for mc in md.values():
+                            if config_path_key in mc:
                                 pass
+                            elif isinstance(mc, list):  # legacy (see below)
+                                mc.append({config_path_key: hier_constraint_source})
+                            elif isinstance(mc, dict):  # new (see below)
+                                mc[config_path_key] = hier_constraint_source
                             else:
-                                md[m].append({hammer_config._CONFIG_PATH_KEY: hier_constraint_source})
-            """
+                                raise TypeError("Invalid type for hierarchical constraints")
+                except:
+                    # No _config_path in the config (from tests)
+                    # Or no vlsi.inputs.hierarchical.constraints in project_conf
+                    continue
             self.update_project_configs(self.project_configs)
             list_of_hier_constraints = self.database.get_setting(
                     "vlsi.inputs.hierarchical.constraints") # type: List[Dict]
-            hier_constraints = reduce(add_dicts, list_of_hier_constraints, {})
+            if len(list_of_hier_constraints) > 0:
+                # Check if the hierarchical constraints are a list of dicts or a dict
+                # list of dicts is for legacy compatibility. Meta directives don't work.
+                if isinstance(next(iter(list_of_hier_constraints[0].values())), list):
+                    self.log.warning("In vlsi.inputs.hierarchical.constraints, specifying constraints for each module " +
+                        "as a list of single key/value pair dicts is deprecated but still functional. " +
+                        "Recommend using a full dict (like in a flat flow) instead.")
+                    hier_constraints = reduce(add_dicts, list_of_hier_constraints, {})
+                else:
+                    # run combine_configs per module to resolve meta directives
+                    for module in all_modules:
+                        mod_configs = list(map(lambda x: x.get(module, {}), list_of_hier_constraints))
+                        hier_constraints[module] = hammer_config.combine_configs(mod_configs)
+
+            # Process manual placement constraints
+            list_of_placement_constraints = self.database.get_setting(
+                "vlsi.inputs.hierarchical.manual_placement_constraints")  # type: List[Dict]
+            assert isinstance(list_of_placement_constraints, list)
+            # Legacy method: derived from module's vlsi.inputs.placement_constraints
+            if len(list_of_placement_constraints) > 0:
+                self.log.warning("Use of vlsi.inputs.hierarchical.manual_placement_constraints is legacy. " +
+                    "Place them constraints inside vlsi.inputs.placement_constraints for each module instead.")
+                combined_raw_placement_dict = reduce(add_dicts, list_of_placement_constraints, {})  # type: Dict[str, List[Dict[str, Any]]]
+
+                # This helper function filters only the dict containing the toplevel placement constraint, if any, from the provided list of dicts.
+                # If the list does not contain a toplevel constraint, it returns None.
+                def get_toplevel(d: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+                    results = list(filter(lambda x: x["type"] == "toplevel", d))
+                    if len(results) == 0:
+                        return None
+                    else:
+                        return results[-1]
+
+                # Use the above helper method to filter down the combined raw placement dict into a dict:
+                # - keys are hierarchical module name
+                # - values are dicts containing toplevel constraints or None
+                toplevels_opt = {k: get_toplevel(v) for k, v in combined_raw_placement_dict.items()}  # type: Dict[str, Optional[Dict[str, Any]]]
+                # This filters out all of the Nones to get only hierarchical modules with toplevel placement constraints
+                toplevels = {k: v for k, v in toplevels_opt.items() if v is not None}  # type: Dict[str, Dict[str, Any]]
+                # This converts each dict entry into a MacroSize tuple, which should now represent all hierarchical modules
+                hier_macros = [MacroSize(library="", name=x[0], width=x[1]["width"], height=x[1]["height"]) for x in toplevels.items()]
+                masters = hard_macros + hier_macros
+
+                hier_placement_constraints = {key: list(map(partial(PlacementConstraint.from_masters_and_dict, masters), lst))
+                                              for key, lst in combined_raw_placement_dict.items()}
         elif hier_source == "from_placement":
             raise NotImplementedError("Generation from placement not implemented yet")
         else:
@@ -1795,11 +1822,19 @@ class HammerDriver:
 
             constraint_dict = {
                 "vlsi.inputs.hierarchical.mode": str(mode),
-                "synthesis.inputs.top_module": module,
-                "vlsi.inputs.placement_constraints": list(
-                    map(PlacementConstraint.to_dict, hier_placement_constraints.get(module, [])))
+                "synthesis.inputs.top_module": module
             }
-            constraint_dict = reduce(add_dicts, hier_constraints.get(module, []), constraint_dict)
+            # manual_placement_constraints specified (legacy)
+            if len(hier_placement_constraints) > 0:
+                constraint_dict = add_dicts(constraint_dict, {
+                    "vlsi.inputs.placement_constraints": list(
+                        map(PlacementConstraint.to_dict, hier_placement_constraints.get(module, [])))
+                })
+            if len(hier_constraints) > 0:
+                if isinstance(next(iter(hier_constraints.values())), dict):  # new
+                    constraint_dict = add_dicts(hier_constraints.get(module, {}), constraint_dict)
+                else:  # legacy
+                    constraint_dict = reduce(add_dicts, hier_constraints.get(module, []), constraint_dict)
             output.append((module, constraint_dict))
 
         return (output, dependency_graph)
diff --git a/hammer/vlsi/hammer_vlsi_impl.py b/hammer/vlsi/hammer_vlsi_impl.py
index 56552b7d..7d92e1e7 100644
--- a/hammer/vlsi/hammer_vlsi_impl.py
+++ b/hammer/vlsi/hammer_vlsi_impl.py
@@ -673,7 +673,9 @@ class HammerPlaceAndRouteTool(HammerTool):
             raise NotImplementedError("Power strap generation method %s is not implemented" % method)
 
 
-    def specify_power_straps_by_tracks(self, layer_name: str, bottom_via_layer: str, blockage_spacing: Decimal, track_pitch: int, track_width: int, track_spacing: int, track_start: int, track_offset: Decimal, bbox: Optional[List[Decimal]], nets: List[str], add_pins: bool, layer_is_all_power: bool, antenna_trim_shape: str) -> List[str]:
+
+    def specify_power_straps_by_tracks(self, layer_name: str, bottom_via_layer: str, blockage_spacing: Decimal, track_pitch: int, track_width: int, track_spacing: int, track_start: int, track_offset: Decimal, bbox: Optional[List[Decimal]], nets: List[str], add_pins: bool, layer_is_all_power: bool, antenna_trim_shape: str, pattern: str) -> List[str]:
+
         """
         Generate a list of TCL commands that will create power straps on a given layer by specifying the desired track consumption.
         This method assumes that power straps are built bottom-up, starting with standard cell rails.
@@ -683,7 +685,7 @@ class HammerPlaceAndRouteTool(HammerTool):
         :param blockage_spacing: The minimum spacing between the end of a strap and the beginning of a macro or blockage.
         :param track_pitch: The integer pitch between groups of power straps (i.e. from left edge of strap A to the next left edge of strap A) in units of the routing pitch.
         :param track_width: The desired number of routing tracks to consume by a single power strap.
-        :param track_spacing: The desired number of USABLE routing tracks between power straps. It is recommended to leave this at 0 except to fix DRC issues.
+        :param track_spacing: The desired number of USABLE routing tracks between power straps (e.g. between VDD and VSS). It is recommended to leave this at 0 except to fix DRC issues.
         :param track_start: The index of the first track to start using for power straps relative to the bounding box.
         :param bbox: The optional (2N)-point bounding box of the area to generate straps. By default the entire core area is used.
         :param nets: A list of power nets to create (e.g. ["VDD", "VSS"], ["VDDA", "VSS", "VDDB"], ... etc.).
@@ -698,6 +700,9 @@ class HammerPlaceAndRouteTool(HammerTool):
         width = Decimal(0)
         spacing = Decimal(0)
         strap_offset = Decimal(0)
+        # Force unit spacing for correct power utilization to reuse twt
+        if pattern == 'mesh':
+            track_spacing = 1 # just for sizing power-straps using twt
         if track_spacing == 0:
             # An all-power (100% utilization) layer results in us wanting to do a uniform strap pattern, so we can just calculate the
             # maximum width and minimum spacing from the desired pitch, instead of using TWWT.
@@ -710,6 +715,9 @@ class HammerPlaceAndRouteTool(HammerTool):
         else:
             width, spacing, strap_start = layer.get_width_spacing_start_twt(track_width, logger=self.logger.context(layer_name))
             spacing = 2*spacing + (track_spacing - 1) * layer.pitch + layer.min_width
+            if pattern == "mesh":
+                spacing = pitch / 2 - width
+
         offset = track_offset + track_start * layer.pitch + strap_start
         assert width > Decimal(0), "Width must be greater than zero. You probably have a malformed tech plugin on layer {}.".format(layer_name)
         assert spacing > Decimal(0), "Spacing must be greater than zero. You probably have a malformed tech plugin on layer {}.".format(layer_name)
@@ -762,7 +770,8 @@ class HammerPlaceAndRouteTool(HammerTool):
             assert layer.index > last.index, "Must build power straps bottom-up"
             if last.direction == layer.direction:
                 raise ValueError("Layers {a} and {b} run in the same direction, but have no power straps between them.".format(a=last.name, b=layer.name))
-
+            
+            pattern = self._get_by_tracks_metal_setting("pattern", layer_name)
             blockage_spacing = coerce_to_grid(float(self._get_by_tracks_metal_setting("blockage_spacing", layer_name)), layer.grid_unit)
             track_width = int(self._get_by_tracks_metal_setting("track_width", layer_name))
             track_spacing = int(self._get_by_tracks_metal_setting("track_spacing", layer_name))
@@ -771,7 +780,6 @@ class HammerPlaceAndRouteTool(HammerTool):
             track_offset = Decimal(str(self._get_by_tracks_metal_setting("track_offset", layer_name)))
             antenna_trim_shape = self._get_by_tracks_metal_setting("antenna_trim_shape", layer_name)
             offset = layer.offset # TODO this is relaxable if we can auto-recalculate this based on hierarchical setting
-
             add_pins = layer_name in pin_layers
             # For multiple domains, we'll stripe them like this:
             # 2:1 :   A A B A A B ...
@@ -785,7 +793,9 @@ class HammerPlaceAndRouteTool(HammerTool):
                 nets = [ground_net, power_nets[i]]
                 group_offset = offset + track_offset + track_pitch * i * layer.pitch
                 group_pitch = sum_weights * track_pitch
-                output.extend(self.specify_power_straps_by_tracks(layer_name, last.name, blockage_spacing, group_pitch, track_width, track_spacing, track_start, group_offset, bbox, nets, add_pins, layer_is_all_power, antenna_trim_shape))
+
+                output.extend(self.specify_power_straps_by_tracks(layer_name, last.name, blockage_spacing, group_pitch, track_width, track_spacing, track_start, group_offset, bbox, nets, add_pins, layer_is_all_power, antenna_trim_shape, pattern))
+
             last = layer
 
         self._dump_power_straps_for_hardmacros()
@@ -1040,13 +1050,17 @@ class HammerPlaceAndRouteTool(HammerTool):
         track_width = int(self._get_by_tracks_metal_setting("track_width", layer_name))
         track_spacing = int(self._get_by_tracks_metal_setting("track_spacing", layer_name))
         power_utilization = float(self._get_by_tracks_metal_setting("power_utilization", layer_name))
+        pattern = self._get_by_tracks_metal_setting("pattern", layer_name)
 
         assert power_utilization > 0.0
         assert power_utilization <= 1.0
 
         # Calculate how many tracks we consume
         # This strategy uses pairs of power and ground
-        consumed_tracks = 2 * track_width + track_spacing
+        if pattern == 'mesh':
+            consumed_tracks = 2 * track_width
+        else:
+            consumed_tracks = 2 * track_width + track_spacing
         return round(consumed_tracks / power_utilization)
 
     @abstractmethod


Test patch:
diff --git a/tests/test_flowgraph.py b/tests/test_flowgraph.py
index a39f0361..0bbff710 100644
--- a/tests/test_flowgraph.py
+++ b/tests/test_flowgraph.py
@@ -1,11 +1,9 @@
-import json
 import os
 import tempfile
 import unittest
 from textwrap import dedent
 
 import networkx as nx
-import pytest
 
 from hammer.vlsi.cli_driver import (
     CLIDriver,
@@ -13,7 +11,6 @@ from hammer.vlsi.cli_driver import (
     HammerToolHookAction
 )
 
-from hammer import flowgraph
 from hammer.flowgraph import convert_to_acyclic, Graph, Node, Status
 from hammer.logging import HammerVLSILogging
 
@@ -469,49 +466,6 @@ class TestFlowgraph(unittest.TestCase):
                 else:
                     self.assertTrue(os.path.exists(file))
 
-    @pytest.mark.skip
-    def test_encode_decode(self) -> None:
-        """
-        Test that a flowgraph can be encoded and decoded.
-        """
-        HammerVLSILogging.clear_callbacks()
-        HammerVLSILogging.add_callback(HammerVLSILogging.callback_buffering)
-
-        with tempfile.TemporaryDirectory() as td:
-            os.mkdir(os.path.join(td, "syn_dir"))
-            os.mkdir(os.path.join(td, "par_dir"))
-
-            with open(os.path.join(td, "syn_dir", "syn-in.yml"), 'w', encoding="utf-8") as tf1:
-                tf1.write(MOCK_CFG)
-                
-            syn = Node(
-                "syn", "nop",
-                os.path.join(td, "syn_dir"), os.path.join(td, "s2p_dir"),
-                ["syn-in.yml"],
-                ["syn-out.json"],
-            )
-            s2p = Node(
-                "syn-to-par", "nop",
-                os.path.join(td, "s2p_dir"), os.path.join(td, "par_dir"),
-                ["syn-out.json"],
-                ["s2p-out.json"],
-            )
-            par = Node(
-                "par", "nop",
-                os.path.join(td, "par_dir"), os.path.join(td, "out_dir"),
-                ["s2p-out.json"],
-                ["par-out.json"],
-            )
-            g = Graph({
-                syn: [s2p],
-                s2p: [par],
-                par: []
-            })
-
-            out = json.dumps(g.to_json(), cls=flowgraph.NodeEncoder)
-            g_dec = json.loads(out, object_hook=flowgraph.as_node)
-            # print(g.to_json())
-            # print(json_graph.node_link_graph(g_dec).nodes)
 
 class NodeDummyDriver(CLIDriver):
     def get_extra_synthesis_hooks(self) -> list[HammerToolHookAction]:
diff --git a/tests/test_power_straps.py b/tests/test_power_straps.py
index 507e8a8d..825d38c7 100644
--- a/tests/test_power_straps.py
+++ b/tests/test_power_straps.py
@@ -69,23 +69,9 @@ def power_straps_test_context(tmp_path, tech_name: str, straps_options: Dict[str
     assert driver.load_par_tool()
     yield PowerStrapsTestContext(temp_dir=tech_dir_base, driver=driver)
 
-
 def simple_straps_options() -> Dict[str, Any]:
     # TODO clean this up a bit
 
-    strap_layers = ["M4", "M5", "M6", "M7", "M8"]
-    pin_layers = ["M7", "M8"]
-    track_width = 4
-    track_width_M7 = 8
-    track_width_M8 = 10
-    track_spacing = 0
-    track_spacing_M6 = 1
-    power_utilization = 0.2
-    power_utilization_M8 = 1.0
-    track_start_M5 = 1
-    track_offset_M5 = 1.2
-    bottom_via_layer = "rail"
-
     # VSS comes before VDD
     nets = ["VSS", "VDD"]
 
@@ -99,32 +85,59 @@ def simple_straps_options() -> Dict[str, Any]:
         "par.power_straps_mode": "generate",
         "par.generate_power_straps_method": "by_tracks",
         "par.generate_power_straps_options.by_tracks": {
-            "strap_layers": strap_layers,
-            "pin_layers": pin_layers,
-            "track_width": track_width,
-            "track_width_M7": track_width_M7,
-            "track_width_M8": track_width_M8,
-            "track_spacing": track_spacing,
-            "track_spacing_M6": track_spacing_M6,
-            "power_utilization": power_utilization,
-            "power_utilization_M8": power_utilization_M8,
-            "track_start_M5": track_start_M5,
-            "track_offset_M5": track_offset_M5,
-            "bottom_via_layer": bottom_via_layer
+
+            "strap_layers": ["M4", "M5", "M6", "M7", "M8"],
+            "pin_layers": ["M7", "M8"],
+            "track_width": 4,
+            "track_width_M7": 8,
+            "track_width_M8": 10,
+            "track_spacing": 0,
+            "track_spacing_M6": 1,
+            "power_utilization": 0.2,
+            "power_utilization_M8": 1.0,
+            "track_start_M5": 1,
+            "track_offset_M5": 1.2,
+            "bottom_via_layer": "rail"
         }
     }
     return straps_options
 
+def mesh_straps_options() -> Dict[str, Any]:
+    # TODO clean this up a bit
 
-def multiple_domains_straps_options() -> Dict[str, Any]:
-    strap_layers = ["M4", "M5", "M8"]
-    pin_layers = ["M8"]
-    track_width = 8
-    track_spacing = 0
-    power_utilization = 0.2
-    power_utilization_M8 = 1.0
-    bottom_via_layer = "rail"
+    # VSS comes before VDD
+    nets = ["VSS", "VDD"]
 
+    straps_options = {
+        "vlsi.inputs.supplies": {
+            "power": [{"name": "VDD", "pins": ["VDD"]}],
+            "ground": [{"name": "VSS", "pins": ["VSS"]}],
+            "VDD": "1.00 V",
+            "GND": "0 V"
+        },
+        "par.power_straps_mode": "generate",
+        "par.generate_power_straps_method": "by_tracks",
+        "par.generate_power_straps_options.by_tracks": {
+            "strap_layers": ["M4", "M5", "M6", "M7", "M8"],
+            "pin_layers": ["M7", "M8"],
+            "track_width": 4,
+            "track_width_M7": 8,
+            "track_width_M8": 10,
+            "track_spacing": 0,
+            "track_spacing_M6": 1,
+            "power_utilization": 0.2,
+            "power_utilization_M8": 1.0,
+            "track_start_M5": 1,
+            "track_offset_M5": 1.2,
+            "pattern": 'coupled',
+            "pattern_M4": 'mesh',
+            "track_spacing_M4": 1,
+            "bottom_via_layer": "rail"
+        }
+    }
+    return straps_options
+
+def multiple_domains_straps_options() -> Dict[str, Any]:
     straps_options = {
         "vlsi.inputs.supplies": {
             "power": [{"name": "VDD", "pins": ["VDD"]}, {"name": "VDD2", "pins": ["VDD2"]}],
@@ -135,13 +148,13 @@ def multiple_domains_straps_options() -> Dict[str, Any]:
         "par.power_straps_mode": "generate",
         "par.generate_power_straps_method": "by_tracks",
         "par.generate_power_straps_options.by_tracks": {
-            "strap_layers": strap_layers,
-            "pin_layers": pin_layers,
-            "track_width": track_width,
-            "track_spacing": track_spacing,
-            "power_utilization": power_utilization,
-            "power_utilization_M8": power_utilization_M8,
-            "bottom_via_layer": bottom_via_layer
+            "strap_layers": ["M4", "M5", "M8"],
+            "pin_layers": ["M8"],
+            "track_width": 8,
+            "track_spacing": 0,
+            "power_utilization": 0.2,
+            "power_utilization_M8": 1.0,
+            "bottom_via_layer": "rail"
         }
     }
     return straps_options
@@ -251,7 +264,117 @@ class TestPowerStrapsTest(HasGetTech):
                 assert strap_pitch == required_pitch
             else:
                 assert False, "Got the wrong layer_name: {}".format(layer_name)
+    
+    @pytest.mark.parametrize("straps_options, tech_name", [(mesh_straps_options(), "mesh")])
+    def test_mesh_power_straps(self, power_straps_test_context) -> None:
+        """ Creates simple power straps using the by_tracks method """
+        c = power_straps_test_context
+        success, par_output = c.driver.run_par()
+        assert success
 
+        par_tool = c.driver.par_tool
+        # It's surpringly annoying to import mockpar.MockPlaceAndRoute, which is the class
+        # that contains the parse_mock_power_straps_file() method, so we're just ignoring
+        # that particular part of this
+        assert isinstance(par_tool, hammer_vlsi.HammerPlaceAndRouteTool)
+        stackup = par_tool.get_stackup()
+        parsed_out = par_tool.parse_mock_power_straps_file()  # type: ignore
+        entries = cast(List[Dict[str, Any]], parsed_out)
+
+        # TODO: this is copied, avoid that
+        strap_layers = ["M4", "M5", "M6", "M7", "M8"]
+        pin_layers = ["M7", "M8"]
+        track_width = 4
+        track_spacing_M4 = 1
+        pattern_M4 = 'mesh'
+        track_width_M7 = 8
+        track_width_M8 = 10
+        track_spacing = 0
+        track_spacing_M6 = 1
+        power_utilization = 0.2
+        power_utilization_M8 = 1.0
+        track_start_M5 = 1
+        track_offset_M5 = 1.2
+        nets = ["VSS", "VDD"]
+
+        for entry in entries:
+            print(entry)
+            c.logger.debug("Power strap entry:" + str(entry))
+            layer_name = entry["layer_name"]
+            if layer_name == "M1":
+                # Standard cell rails
+                assert entry["tap_cell_name"] == "FakeTapCell"
+                assert entry["bbox"] == []
+                assert entry["nets"] == nets
+                continue
+
+            strap_width = Decimal(entry["width"])
+            strap_spacing = Decimal(entry["spacing"])
+            strap_pitch = Decimal(entry["pitch"])
+            strap_offset = Decimal(entry["offset"])
+            metal = stackup.get_metal(layer_name)
+            min_width = metal.min_width
+            group_track_pitch = strap_pitch / metal.pitch
+            track_offset = Decimal(str(track_offset_M5)) if layer_name == "M5" else Decimal(0)
+            track_start = Decimal(str(track_start_M5)) if layer_name == "M5" else Decimal(0)
+            used_tracks = round(Decimal(strap_offset + strap_width + strap_spacing + strap_width + strap_offset - 2 * (track_offset + track_start * metal.pitch)) / metal.pitch) - 1
+            if layer_name == "M4":
+                assert entry["bbox"] == []
+                assert entry["nets"] == nets
+                # TODO more tests in a future PR
+                assert strap_spacing == strap_pitch / 2 - strap_width
+                assert strap_pitch / metal.pitch == track_width * 2 / power_utilization 
+            elif layer_name == "M5":
+                assert entry["bbox"] == []
+                assert entry["nets"] == nets
+                # Check that the requested tracks equals the used tracks
+                requested_tracks = track_width * 2 + track_spacing
+                assert used_tracks == requested_tracks
+                # Spacing should be at least the min spacing
+                min_spacing = metal.get_spacing_for_width(strap_width)
+                assert strap_spacing >= min_spacing
+                # TODO more tests in a future PR
+                assert strap_pitch / metal.pitch == (track_width * 2 + track_spacing) / power_utilization 
+            elif layer_name == "M6":
+                assert entry["bbox"] == []
+                assert entry["nets"] == nets
+                # This is a sanity check that we didn't accidentally change something up above
+                assert track_spacing_M6 == 1
+                # We should be able to fit a track in between the stripes because track_spacing_M6 == 1
+                wire_to_strap_spacing = (strap_spacing - min_width) / 2
+                min_spacing = metal.get_spacing_for_width(strap_width)
+                assert wire_to_strap_spacing >= min_spacing
+                # Check that the requested tracks equals the used tracks
+                requested_tracks = track_width * 2 + track_spacing_M6
+                assert used_tracks == requested_tracks
+                # Spacing should be at least the min spacing
+                min_spacing = metal.get_spacing_for_width(strap_width)
+                assert wire_to_strap_spacing >= min_spacing
+                # TODO more tests in a future PR
+            elif layer_name == "M7":
+                assert entry["bbox"] == []
+                assert entry["nets"] == nets
+                # TODO more tests in a future PR
+            elif layer_name == "M8":
+                other_spacing = strap_pitch - (2 * strap_width) - strap_spacing
+                # Track spacing should be 0
+                assert track_spacing == 0
+                # Test that the power straps are symmetric
+                assert other_spacing == strap_spacing
+                # Spacing should be at least the min spacing
+                min_spacing = metal.get_spacing_for_width(strap_width)
+                assert other_spacing >= min_spacing
+                # Test that a slightly larger strap would be a DRC violation
+                new_spacing = metal.get_spacing_for_width(strap_width + metal.grid_unit)
+                new_pitch = (strap_width + metal.grid_unit + new_spacing) * 2
+                assert strap_pitch < new_pitch
+                # Test that the pitch does consume the right number of tracks
+                required_pitch = Decimal(track_width_M8 * 2) * metal.pitch
+                # 100% power utilzation should produce straps that consume 2*strap_width + strap_spacing tracks
+                assert strap_pitch == required_pitch
+            else:
+                assert False, "Got the wrong layer_name: {}".format(layer_name)
+    
     @pytest.mark.parametrize("straps_options, tech_name", [(multiple_domains_straps_options(), "multiple_domains")])
     def test_multiple_domains(self, power_straps_test_context) -> None:
         """ Tests multiple power domains """