Repository: Qiskit/rustworkx (language: python)

Issue:
Add support to `hexagonal_lattice_graph` for periodcity and position coordinates
### What is the expected enhancement?
networkx's [`hexagonal_lattice_graph`](https://networkx.org/documentation/stable/reference/generated/networkx.generators.lattice.hexagonal_lattice_graph.html) function provides support for periodicity and setting position coordinates, and it would be great if rustworkx's `hexagonal_lattice_graph` did the same.

Definition of done:
- a `periodic` bool argument added to `hexagonal_lattice_graph`
- a `with_positions` bool argument added to `hexagonal_lattice_graph`

cc @mrossinek




Discussion / hints:
You can assign this issue to @javabster and myself :+1: 
One small wrinkle you'll face implementing this is the actual code generating the hexagonal lattice graph was recently migrated from the python side of the library to `rustworkx-core` which is an inner pure rust library to enable Rust consumers to leverage the functionality we've written: https://github.com/Qiskit/rustworkx/blob/main/rustworkx-core/src/generators/hexagonal_lattice_graph.rs  

The python side of the library is now just calling that generic rust function: https://github.com/Qiskit/rustworkx/blob/main/src/generators.rs#L1112-L1158 so to implement this `periodic` will be straightforward, but for `with_positions` on the `rustworkx-core` side you'll need to be a bit more general/generic in how to handle that because the input graphs because for rustworkx-core we don't make any assumptions on what the types are for node and edge payloads. I think the easiest way to do this is basically to add a new argument `with_positions` of type `Option<F>` where `F` is constrained to `FnMut([usize; 2]) -> T` or something like that. This will let users define how to go from a position to whatever type their graph contains.

For example, for the python side this is simple because you can just do:
```rust
let pos_to_py = |pos| -> PyObject {
    pos.to_object(py)
}
```

in the python side and set `Some(pos_to_py)` as the argument on the inner call if the boolean flag is true.
An update on our investigation:

We've compared how networkx handles periodicity for it's hexagonal lattices and other lattice types. Some lattice types add an extra edge between nodes, but the hexagonal lattice method actually combines nodes together (using a `contracted_nodes` function). This node contraction method seems to be the best option in the hexagonal lattice case as it enables users to maintain the rows and columns format as input arguments, and using that method here in rustworkx will keep feature parity with networkx. However the `contracted_nodes` logic in networkx is a bit difficult to follow so our next step is to investigate if there is a simpler way to implement this functionality. Additionally, we think it would be beneficial for users to be able to specify which direction to implement the periodicity. Networkx implements periodicity in all directions only, however we think there are potential use cases where a user might want to generate a hexagonal lattice which is periodic in just the x or just the y direction, so we will be looking into this implementation further.

cc @mrossinek 
hi @mtreinish we've been looking into how to best implement the periodic part of this and are first looking to mimic the logic used in[ networkx](https://networkx.org/documentation/stable/_modules/networkx/generators/lattice.html#hexagonal_lattice_graph) in `rustworkx-core/src/generators/hexagonal_lattice`. We see that `rustworkx` has a `contract_nodes` function (similar to the networkx `contracted_nodes`) that we were considering using, but it seems like that method is only accessible from the python layer of rustworkx, not in rustworkx core? Do you have a recommendation for how best to proceed? We were considering options like only implementing periodicity from the python layer, or somehow refactoring `contract_nodes` to be usable in `rustworkx-core`. Not sure what would be the best option and would appreciate your input 😄 
Yeah, that's correct the `contract_nodes` function only exists as a method on `PyDiGraph` and `PyGraph`. The best path forward would probably be to implement `contract_nodes` as a generic graph function in `rustworkx-core`. It shouldn't be too terrible to port over and update the method to call rustworkx-core internally. That would also let us deduplicate the function between the two graph classes. In general I tend to favor putting more functionality in rustworkx-core, if feasible, because that's expands the usability of the functionality to both rust and python.

That being said looking at the networkx logic the one complexity you'll have following that as a model is node remove (either directly or via contraction) will result in index holes in the output graph. This can be confusing for the generator functions as you basically can get a graph out that has the node indices `[0, 1, 3, 4]`. It's not a deal breaker (and IIRC the lattice generators used to do this until #397) but it is something to watch out for and document when you do implement it the algorithm.

Fix patch:
diff --git a/rustworkx-core/src/generators/hexagonal_lattice_graph.rs b/rustworkx-core/src/generators/hexagonal_lattice_graph.rs
index 7adbba201..0f6571e38 100644
--- a/rustworkx-core/src/generators/hexagonal_lattice_graph.rs
+++ b/rustworkx-core/src/generators/hexagonal_lattice_graph.rs
@@ -17,6 +17,184 @@ use petgraph::visit::{Data, NodeIndexable};
 
 use super::InvalidInputError;
 
+mod utils {
+    use super::*;
+
+    pub struct HexagonalLatticeBuilder {
+        rowlen: usize,    // Number of nodes in each vertical chain
+        collen: usize,    // Number of vertical chains
+        num_nodes: usize, // Total number of nodes
+        bidirectional: bool,
+        periodic: bool,
+    }
+
+    impl HexagonalLatticeBuilder {
+        pub fn new(
+            rows: usize,
+            cols: usize,
+            bidirectional: bool,
+            periodic: bool,
+        ) -> Result<HexagonalLatticeBuilder, InvalidInputError> {
+            if periodic && (cols % 2 == 1 || rows < 2 || cols < 2) {
+                return Err(InvalidInputError {});
+            }
+
+            let (rowlen, collen, num_nodes) = if periodic {
+                let r_len = 2 * rows;
+                (r_len, cols, r_len * cols)
+            } else {
+                let r_len = 2 * rows + 2;
+                (r_len, cols + 1, r_len * (cols + 1) - 2)
+            };
+
+            Ok(HexagonalLatticeBuilder {
+                rowlen,
+                collen,
+                num_nodes,
+                bidirectional,
+                periodic,
+            })
+        }
+
+        pub fn build_with_default_node_weight<G, T, F, H, M>(
+            self,
+            mut default_node_weight: F,
+            default_edge_weight: H,
+        ) -> G
+        where
+            G: Build + Create + Data<NodeWeight = T, EdgeWeight = M> + NodeIndexable,
+            F: FnMut() -> T,
+            H: FnMut() -> M,
+            G::NodeId: Eq + Hash,
+        {
+            // ToDo: be more precise about the number of edges
+            let mut graph = G::with_capacity(self.num_nodes, self.num_nodes);
+            let nodes: Vec<G::NodeId> = (0..self.num_nodes)
+                .map(|_| graph.add_node(default_node_weight()))
+                .collect();
+            self.add_edges(&mut graph, nodes, default_edge_weight);
+
+            graph
+        }
+
+        pub fn build_with_position_dependent_node_weight<G, T, F, H, M>(
+            self,
+            mut node_weight: F,
+            default_edge_weight: H,
+        ) -> G
+        where
+            G: Build + Create + Data<NodeWeight = T, EdgeWeight = M> + NodeIndexable,
+            F: FnMut(usize, usize) -> T,
+            H: FnMut() -> M,
+            G::NodeId: Eq + Hash,
+        {
+            // ToDo: be more precise about the number of edges
+            let mut graph = G::with_capacity(self.num_nodes, self.num_nodes);
+
+            let lattice_position = |n| -> (usize, usize) {
+                if self.periodic {
+                    (n / self.rowlen, n % self.rowlen)
+                } else {
+                    // In the non-periodic case the first and last vertical
+                    // chains have rowlen - 1 = 2 * rows + 1 nodes. All others
+                    // have rowlen = 2 * rows + 2 nodes.
+                    if n < self.rowlen - 1 {
+                        (0, n)
+                    } else {
+                        let k = n - (self.rowlen - 1);
+                        let u = k / self.rowlen + 1;
+                        let v = k % self.rowlen;
+                        if u == self.collen - 1 {
+                            (u, v + 1)
+                        } else {
+                            (u, v)
+                        }
+                    }
+                }
+            };
+
+            let nodes: Vec<G::NodeId> = (0..self.num_nodes)
+                .map(lattice_position)
+                .map(|(u, v)| graph.add_node(node_weight(u, v)))
+                .collect();
+            self.add_edges(&mut graph, nodes, default_edge_weight);
+
+            graph
+        }
+
+        fn add_edges<G, H, M>(
+            &self,
+            graph: &mut G,
+            nodes: Vec<G::NodeId>,
+            mut default_edge_weight: H,
+        ) where
+            G: Build + NodeIndexable + Data<EdgeWeight = M>,
+            H: FnMut() -> M,
+        {
+            let mut add_edge = |u, v| {
+                graph.add_edge(nodes[u], nodes[v], default_edge_weight());
+                if self.bidirectional {
+                    graph.add_edge(nodes[v], nodes[u], default_edge_weight());
+                }
+            };
+
+            if self.periodic {
+                // Add column edges
+                for i in 0..self.collen {
+                    let col_start = i * self.rowlen;
+                    for j in col_start..(col_start + self.rowlen - 1) {
+                        add_edge(j, j + 1);
+                    }
+                    add_edge(col_start + self.rowlen - 1, col_start);
+                }
+                // Add row edges
+                for i in 0..self.collen {
+                    let col_start = i * self.rowlen + i % 2;
+                    for j in (col_start..(col_start + self.rowlen)).step_by(2) {
+                        add_edge(j, (j + self.rowlen) % self.num_nodes);
+                    }
+                }
+            } else {
+                // Add column edges
+                for j in 0..(self.rowlen - 2) {
+                    add_edge(j, j + 1);
+                }
+                for i in 1..(self.collen - 1) {
+                    for j in 0..(self.rowlen - 1) {
+                        add_edge(i * self.rowlen + j - 1, i * self.rowlen + j);
+                    }
+                }
+                for j in 0..(self.rowlen - 2) {
+                    add_edge(
+                        (self.collen - 1) * self.rowlen + j - 1,
+                        (self.collen - 1) * self.rowlen + j,
+                    );
+                }
+
+                // Add row edges
+                for j in (0..(self.rowlen - 1)).step_by(2) {
+                    add_edge(j, j + self.rowlen - 1);
+                }
+                for i in 1..(self.collen - 2) {
+                    for j in 0..self.rowlen {
+                        if i % 2 == j % 2 {
+                            add_edge(i * self.rowlen + j - 1, (i + 1) * self.rowlen + j - 1);
+                        }
+                    }
+                }
+                if self.collen > 2 {
+                    for j in ((self.collen % 2)..self.rowlen).step_by(2) {
+                        add_edge(
+                            (self.collen - 2) * self.rowlen + j - 1,
+                            (self.collen - 1) * self.rowlen + j - 1 - (self.collen % 2),
+                        );
+                    }
+                }
+            }
+        }
+    }
+}
+
 /// Generate a hexagonal lattice graph
 ///
 /// Arguments:
@@ -24,12 +202,15 @@ use super::InvalidInputError;
 /// * `rows` - The number of rows to generate the graph with.
 /// * `cols` - The number of columns to generate the graph with.
 /// * `default_node_weight` - A callable that will return the weight to use
-///     for newly created nodes. This is ignored if `weights` is specified.
+///     for newly created nodes.
 /// * `default_edge_weight` - A callable that will return the weight object
 ///     to use for newly created edges.
 /// * `bidirectional` - Whether edges are added bidirectionally. If set to
 ///     `true` then for any edge `(u, v)` an edge `(v, u)` will also be added.
 ///     If the graph is undirected this will result in a parallel edge.
+/// * `periodic` - If set to `true`, the boundaries of the lattice will be
+///     joined to form a periodic grid. Requires `cols` to be even,
+///     `rows > 1`, and `cols > 1`.
 ///
 /// # Example
 /// ```rust
@@ -42,6 +223,7 @@ use super::InvalidInputError;
 ///     2,
 ///     || {()},
 ///     || {()},
+///     false,
 ///     false
 /// ).unwrap();
 /// let expected_edges = vec![
@@ -75,9 +257,10 @@ use super::InvalidInputError;
 pub fn hexagonal_lattice_graph<G, T, F, H, M>(
     rows: usize,
     cols: usize,
-    mut default_node_weight: F,
-    mut default_edge_weight: H,
+    default_node_weight: F,
+    default_edge_weight: H,
     bidirectional: bool,
+    periodic: bool,
 ) -> Result<G, InvalidInputError>
 where
     G: Build + Create + Data<NodeWeight = T, EdgeWeight = M> + NodeIndexable,
@@ -88,107 +271,149 @@ where
     if rows == 0 || cols == 0 {
         return Ok(G::with_capacity(0, 0));
     }
-    let mut rowlen = rows;
-    let mut collen = cols;
 
-    // Needs two times the number of nodes vertically
-    rowlen = 2 * rowlen + 2;
-    collen += 1;
-    let num_nodes = rowlen * collen - 2;
+    let builder = utils::HexagonalLatticeBuilder::new(rows, cols, bidirectional, periodic)?;
 
-    let mut graph = G::with_capacity(num_nodes, num_nodes);
+    let graph = builder
+        .build_with_default_node_weight::<G, T, F, H, M>(default_node_weight, default_edge_weight);
 
-    let nodes: Vec<G::NodeId> = (0..num_nodes)
-        .map(|_| graph.add_node(default_node_weight()))
-        .collect();
+    Ok(graph)
+}
 
-    // Add column edges
-    for j in 0..(rowlen - 2) {
-        graph.add_edge(nodes[j], nodes[j + 1], default_edge_weight());
-        if bidirectional {
-            graph.add_edge(nodes[j + 1], nodes[j], default_edge_weight());
-        }
-    }
-    for i in 1..(collen - 1) {
-        for j in 0..(rowlen - 1) {
-            graph.add_edge(
-                nodes[i * rowlen + j - 1],
-                nodes[i * rowlen + j],
-                default_edge_weight(),
-            );
-            if bidirectional {
-                graph.add_edge(
-                    nodes[i * rowlen + j],
-                    nodes[i * rowlen + j - 1],
-                    default_edge_weight(),
-                );
-            }
-        }
-    }
-    for j in 0..(rowlen - 2) {
-        graph.add_edge(
-            nodes[(collen - 1) * rowlen + j - 1],
-            nodes[(collen - 1) * rowlen + j],
-            default_edge_weight(),
-        );
-        if bidirectional {
-            graph.add_edge(
-                nodes[(collen - 1) * rowlen + j],
-                nodes[(collen - 1) * rowlen + j - 1],
-                default_edge_weight(),
-            );
-        }
+/// Generate a hexagonal lattice graph where each node is assigned a weight
+/// depending on its position in the lattice.
+///
+/// Arguments:
+///
+/// * `rows` - The number of rows to generate the graph with.
+/// * `cols` - The number of columns to generate the graph with.
+/// * `node_weight` - A callable that will return the weight to use
+///     for newly created nodes. Must take two arguments `i` and `j` of
+///     type `usize`, where `(i, j)` gives the position of the node
+///     in the lattice.
+/// * `default_edge_weight` - A callable that will return the weight object
+///     to use for newly created edges.
+/// * `bidirectional` - Whether edges are added bidirectionally. If set to
+///     `true` then for any edge `(u, v)` an edge `(v, u)` will also be added.
+///     If the graph is undirected this will result in a parallel edge.
+/// * `periodic` - If set to `true`, the boundaries of the lattice will be
+///     joined to form a periodic grid. Requires `cols` to be even,
+///     `rows > 1`, and `cols > 1`.
+///
+/// # Example
+/// ```rust
+/// use rustworkx_core::petgraph;
+/// use rustworkx_core::generators::hexagonal_lattice_graph_weighted;
+/// use rustworkx_core::petgraph::visit::{IntoNodeReferences, NodeRef};
+///
+/// let g: petgraph::graph::UnGraph<(usize, usize), ()> = hexagonal_lattice_graph_weighted(
+///     2,
+///     2,
+///     |u, v| {(u, v)},
+///     || {()},
+///     false,
+///     false
+/// ).unwrap();
+/// let expected_node_weights = vec![
+///     (0, 0),
+///     (0, 1),
+///     (0, 2),
+///     (0, 3),
+///     (0, 4),
+///     (1, 0),
+///     (1, 1),
+///     (1, 2),
+///     (1, 3),
+///     (1, 4),
+///     (1, 5),
+///     (2, 1),
+///     (2, 2),
+///     (2, 3),
+///     (2, 4),
+///     (2, 5),
+/// ];
+/// assert_eq!(
+///     expected_node_weights,
+///     g.node_references()
+///         .map(|node| *node.weight())
+///         .collect::<Vec<(usize, usize)>>(),
+/// )
+/// ```
+pub fn hexagonal_lattice_graph_weighted<G, T, F, H, M>(
+    rows: usize,
+    cols: usize,
+    node_weight: F,
+    default_edge_weight: H,
+    bidirectional: bool,
+    periodic: bool,
+) -> Result<G, InvalidInputError>
+where
+    G: Build + Create + Data<NodeWeight = T, EdgeWeight = M> + NodeIndexable,
+    F: FnMut(usize, usize) -> T,
+    H: FnMut() -> M,
+    G::NodeId: Eq + Hash,
+{
+    if rows == 0 || cols == 0 {
+        return Ok(G::with_capacity(0, 0));
     }
 
-    // Add row edges
-    for j in (0..(rowlen - 1)).step_by(2) {
-        graph.add_edge(nodes[j], nodes[j + rowlen - 1], default_edge_weight());
-        if bidirectional {
-            graph.add_edge(nodes[j + rowlen - 1], nodes[j], default_edge_weight());
-        }
-    }
-    for i in 1..(collen - 2) {
-        for j in 0..rowlen {
-            if i % 2 == j % 2 {
-                graph.add_edge(
-                    nodes[i * rowlen + j - 1],
-                    nodes[(i + 1) * rowlen + j - 1],
-                    default_edge_weight(),
-                );
-                if bidirectional {
-                    graph.add_edge(
-                        nodes[(i + 1) * rowlen + j - 1],
-                        nodes[i * rowlen + j - 1],
-                        default_edge_weight(),
-                    );
-                }
-            }
-        }
-    }
-    if collen > 2 {
-        for j in ((collen % 2)..rowlen).step_by(2) {
-            graph.add_edge(
-                nodes[(collen - 2) * rowlen + j - 1],
-                nodes[(collen - 1) * rowlen + j - 1 - (collen % 2)],
-                default_edge_weight(),
-            );
-            if bidirectional {
-                graph.add_edge(
-                    nodes[(collen - 1) * rowlen + j - 1 - (collen % 2)],
-                    nodes[(collen - 2) * rowlen + j - 1],
-                    default_edge_weight(),
-                );
-            }
-        }
-    }
+    let builder = utils::HexagonalLatticeBuilder::new(rows, cols, bidirectional, periodic)?;
+
+    let graph = builder.build_with_position_dependent_node_weight::<G, T, F, H, M>(
+        node_weight,
+        default_edge_weight,
+    );
+
     Ok(graph)
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::generators::hexagonal_lattice_graph;
+    use crate::generators::{hexagonal_lattice_graph, hexagonal_lattice_graph_weighted};
     use crate::petgraph;
-    use crate::petgraph::visit::EdgeRef;
+    use crate::petgraph::visit::{EdgeRef, IntoNodeReferences};
+    use std::collections::HashSet;
+
+    fn check_expected_edges_directed<T>(
+        graph: &petgraph::graph::DiGraph<T, ()>,
+        expected_edges: &Vec<(usize, usize)>,
+    ) {
+        assert_eq!(graph.edge_count(), expected_edges.len());
+
+        let edge_set: HashSet<(usize, usize)> = graph
+            .edge_references()
+            .map(|edge| (edge.source().index(), edge.target().index()))
+            .collect();
+        let expected_set: HashSet<(usize, usize)> = expected_edges.iter().map(|&e| e).collect();
+        assert_eq!(edge_set, expected_set);
+    }
+
+    fn check_expected_edges_undirected(
+        graph: &petgraph::graph::UnGraph<(), ()>,
+        expected_edges: &Vec<(usize, usize)>,
+    ) {
+        assert_eq!(graph.edge_count(), expected_edges.len());
+
+        let sorted_pair = |(a, b)| {
+            if a > b {
+                (b, a)
+            } else {
+                (a, b)
+            }
+        };
+
+        let edge_set: HashSet<(usize, usize)> = graph
+            .edge_references()
+            .map(|edge| (edge.source().index(), edge.target().index()))
+            .map(|e| sorted_pair(e))
+            .collect();
+        let expected_set: HashSet<(usize, usize)> = expected_edges
+            .iter()
+            .map(|&e| e)
+            .map(|e| sorted_pair(e))
+            .collect();
+        assert_eq!(edge_set, expected_set);
+    }
 
     #[test]
     fn test_hexagonal_lattice_graph() {
@@ -214,15 +439,9 @@ mod tests {
             (10, 15),
         ];
         let g: petgraph::graph::UnGraph<(), ()> =
-            hexagonal_lattice_graph(2, 2, || (), || (), false).unwrap();
+            hexagonal_lattice_graph(2, 2, || (), || (), false, false).unwrap();
         assert_eq!(g.node_count(), 16);
-        assert_eq!(g.edge_count(), expected_edges.len());
-        assert_eq!(
-            expected_edges,
-            g.edge_references()
-                .map(|edge| (edge.source().index(), edge.target().index()))
-                .collect::<Vec<(usize, usize)>>(),
-        );
+        check_expected_edges_undirected(&g, &expected_edges);
     }
 
     #[test]
@@ -249,15 +468,14 @@ mod tests {
             (10, 15),
         ];
         let g: petgraph::graph::DiGraph<(), ()> =
-            hexagonal_lattice_graph(2, 2, || (), || (), false).unwrap();
+            hexagonal_lattice_graph(2, 2, || (), || (), false, false).unwrap();
         assert_eq!(g.node_count(), 16);
-        assert_eq!(g.edge_count(), expected_edges.len());
-        assert_eq!(
-            expected_edges,
-            g.edge_references()
-                .map(|edge| (edge.source().index(), edge.target().index()))
-                .collect::<Vec<(usize, usize)>>(),
-        );
+        check_expected_edges_directed(&g, &expected_edges);
+
+        let g_weighted: petgraph::graph::DiGraph<(usize, usize), ()> =
+            hexagonal_lattice_graph_weighted(2, 2, |u, v| (u, v), || (), false, false).unwrap();
+        assert_eq!(g_weighted.node_count(), 16);
+        check_expected_edges_directed(&g_weighted, &expected_edges);
     }
 
     #[test]
@@ -303,22 +521,141 @@ mod tests {
             (15, 10),
         ];
         let g: petgraph::graph::DiGraph<(), ()> =
-            hexagonal_lattice_graph(2, 2, || (), || (), true).unwrap();
+            hexagonal_lattice_graph(2, 2, || (), || (), true, false).unwrap();
         assert_eq!(g.node_count(), 16);
-        assert_eq!(g.edge_count(), expected_edges.len());
-        assert_eq!(
-            expected_edges,
-            g.edge_references()
-                .map(|edge| (edge.source().index(), edge.target().index()))
-                .collect::<Vec<(usize, usize)>>(),
-        );
+        check_expected_edges_directed(&g, &expected_edges);
     }
 
     #[test]
     fn test_hexagonal_lattice_error() {
         let g: petgraph::graph::UnGraph<(), ()> =
-            hexagonal_lattice_graph(0, 0, || (), || (), false).unwrap();
+            hexagonal_lattice_graph(0, 0, || (), || (), false, false).unwrap();
         assert_eq!(g.node_count(), 0);
         assert_eq!(g.edge_count(), 0);
     }
+
+    #[test]
+    fn test_hexagonal_lattice_periodic_error() {
+        match hexagonal_lattice_graph::<petgraph::graph::UnGraph<(), ()>, (), _, _, ()>(
+            5,
+            3,
+            || (),
+            || (),
+            false,
+            true,
+        ) {
+            Ok(_) => panic!("Returned a non-error"),
+            Err(e) => assert_eq!(e, crate::generators::InvalidInputError),
+        }
+    }
+
+    #[test]
+    fn test_hexagonal_lattice_graph_periodic() {
+        let expected_edges = vec![
+            (0, 1),
+            (1, 2),
+            (2, 3),
+            (3, 0),
+            (4, 5),
+            (5, 6),
+            (6, 7),
+            (7, 4),
+            (0, 4),
+            (2, 6),
+            (5, 1),
+            (7, 3),
+        ];
+        let g: petgraph::graph::UnGraph<(), ()> =
+            hexagonal_lattice_graph(2, 2, || (), || (), false, true).unwrap();
+        assert_eq!(g.node_count(), 8);
+        check_expected_edges_undirected(&g, &expected_edges);
+    }
+
+    #[test]
+    fn test_directed_hexagonal_lattice_graph_periodic() {
+        let expected_edges = vec![
+            (0, 1),
+            (1, 2),
+            (2, 3),
+            (3, 0),
+            (4, 5),
+            (5, 6),
+            (6, 7),
+            (7, 4),
+            (0, 4),
+            (2, 6),
+            (5, 1),
+            (7, 3),
+        ];
+        let g: petgraph::graph::DiGraph<(), ()> =
+            hexagonal_lattice_graph(2, 2, || (), || (), false, true).unwrap();
+        assert_eq!(g.node_count(), 8);
+        check_expected_edges_directed(&g, &expected_edges);
+
+        let g_weighted: petgraph::graph::DiGraph<(usize, usize), ()> =
+            hexagonal_lattice_graph_weighted(2, 2, |u, v| (u, v), || (), false, true).unwrap();
+        assert_eq!(g_weighted.node_count(), 8);
+        check_expected_edges_directed(&g_weighted, &expected_edges);
+
+        let g: petgraph::graph::UnGraph<(usize, usize), ()> =
+            hexagonal_lattice_graph_weighted(2, 2, |u, v| (u, v), || (), false, false).unwrap();
+        let expected_node_weights = vec![
+            (0, 0),
+            (0, 1),
+            (0, 2),
+            (0, 3),
+            (0, 4),
+            (1, 0),
+            (1, 1),
+            (1, 2),
+            (1, 3),
+            (1, 4),
+            (1, 5),
+            (2, 1),
+            (2, 2),
+            (2, 3),
+            (2, 4),
+            (2, 5),
+        ];
+        assert_eq!(
+            expected_node_weights,
+            g.node_references()
+                .map(|node| *node.1)
+                .collect::<Vec<(usize, usize)>>(),
+        )
+    }
+
+    #[test]
+    fn test_directed_hexagonal_lattice_graph_bidirectional_periodic() {
+        let expected_edges = vec![
+            (0, 1),
+            (1, 0),
+            (1, 2),
+            (2, 1),
+            (2, 3),
+            (3, 2),
+            (3, 0),
+            (0, 3),
+            (4, 5),
+            (5, 4),
+            (5, 6),
+            (6, 5),
+            (6, 7),
+            (7, 6),
+            (7, 4),
+            (4, 7),
+            (0, 4),
+            (4, 0),
+            (2, 6),
+            (6, 2),
+            (5, 1),
+            (1, 5),
+            (7, 3),
+            (3, 7),
+        ];
+        let g: petgraph::graph::DiGraph<(), ()> =
+            hexagonal_lattice_graph(2, 2, || (), || (), true, true).unwrap();
+        assert_eq!(g.node_count(), 8);
+        check_expected_edges_directed(&g, &expected_edges);
+    }
 }
diff --git a/rustworkx-core/src/generators/mod.rs b/rustworkx-core/src/generators/mod.rs
index bde826cbf..6c0af1ace 100644
--- a/rustworkx-core/src/generators/mod.rs
+++ b/rustworkx-core/src/generators/mod.rs
@@ -54,7 +54,7 @@ pub use full_rary_tree_graph::full_rary_tree_graph;
 pub use grid_graph::grid_graph;
 pub use heavy_hex_graph::heavy_hex_graph;
 pub use heavy_square_graph::heavy_square_graph;
-pub use hexagonal_lattice_graph::hexagonal_lattice_graph;
+pub use hexagonal_lattice_graph::{hexagonal_lattice_graph, hexagonal_lattice_graph_weighted};
 pub use lollipop_graph::lollipop_graph;
 pub use path_graph::path_graph;
 pub use petersen_graph::petersen_graph;
diff --git a/rustworkx/generators/__init__.pyi b/rustworkx/generators/__init__.pyi
index 03b626940..99946bed8 100644
--- a/rustworkx/generators/__init__.pyi
+++ b/rustworkx/generators/__init__.pyi
@@ -90,12 +90,16 @@ def full_rary_tree(
     weights: Sequence[Any] | None = ...,
     multigraph: bool = ...,
 ) -> PyGraph: ...
-def hexagonal_lattice_graph(rows: int, cols: int, multigraph: bool = ...) -> PyGraph: ...
+def hexagonal_lattice_graph(
+    rows: int, cols: int, multigraph: bool = ..., periodic: bool = ..., with_positions: bool = ...
+) -> PyGraph: ...
 def directed_hexagonal_lattice_graph(
     rows: int,
     cols: int,
     bidirectional: bool = ...,
     multigraph: bool = ...,
+    periodic: bool = ...,
+    with_positions: bool = ...,
 ) -> PyDiGraph: ...
 def lollipop_graph(
     num_mesh_nodes: int | None = ...,
diff --git a/src/generators.rs b/src/generators.rs
index df4074d37..7d41a10b4 100644
--- a/src/generators.rs
+++ b/src/generators.rs
@@ -14,7 +14,7 @@ use petgraph::algo;
 use petgraph::prelude::*;
 use petgraph::Undirected;
 
-use pyo3::exceptions::{PyIndexError, PyOverflowError};
+use pyo3::exceptions::{PyIndexError, PyOverflowError, PyValueError};
 use pyo3::prelude::*;
 use pyo3::wrap_pyfunction;
 use pyo3::Python;
@@ -1103,6 +1103,12 @@ pub fn full_rary_tree(
     })
 }
 
+fn _hexagonal_lattice_node_position(u: usize, v: usize) -> (f64, f64) {
+    let [i, j, a, b, c] = [u, v, u / 2, v % 2, u % 2].map(|val| val as f64);
+    const SQRT3: f64 = 1.732_050_807_568_877_2_f64;
+    (0.5 + i + a + b * (c - 0.5), SQRT3 * j)
+}
+
 /// Generate an undirected hexagonal lattice graph.
 ///
 /// :param int rows: The number of rows to generate the graph with.
@@ -1111,6 +1117,12 @@ pub fn full_rary_tree(
 ///     :class:`~rustworkx.PyGraph` object will not be not be a multigraph and
 ///     won't allow parallel edges to be added. Instead
 ///     calls which would create a parallel edge will update the existing edge.
+/// :param bool periodic: When set to ``True`` the boundaries of the lattice
+///     will be joined to form a periodic grid. Requires ``cols`` to be even,
+///     ``rows > 1``, and ``cols > 1``.
+/// :param bool with_positions: When set to ``True`` each node will be assigned
+///     a pair of coordinates ``(x, y)`` as a weight. This embeds the nodes in
+///     the plane so that each hexagon is regular (with side length 2).
 ///
 /// :returns: The generated hexagonal lattice graph.
 ///
@@ -1128,20 +1140,39 @@ pub fn full_rary_tree(
 ///
 #[pyfunction]
 #[pyo3(
-    signature=(rows, cols, multigraph=true),
+    signature=(rows, cols, multigraph=true, periodic=false, with_positions=false),
 )]
 pub fn hexagonal_lattice_graph(
     py: Python,
     rows: usize,
     cols: usize,
     multigraph: bool,
+    periodic: bool,
+    with_positions: bool,
 ) -> PyResult<graph::PyGraph> {
     let default_fn = || py.None();
-    let graph: StablePyGraph<Undirected> =
-        match core_generators::hexagonal_lattice_graph(rows, cols, default_fn, default_fn, false) {
+    let graph: StablePyGraph<Undirected> = if with_positions {
+        let node_position_fn =
+            |u: usize, v: usize| _hexagonal_lattice_node_position(u, v).to_object(py);
+        match core_generators::hexagonal_lattice_graph_weighted(
+            rows,
+            cols,
+            node_position_fn,
+            default_fn,
+            false,
+            periodic,
+        ) {
             Ok(graph) => graph,
-            Err(_) => return Err(PyIndexError::new_err("rows and cols not specified")),
-        };
+            Err(_) => return Err(PyValueError::new_err("Invalid arguments")),
+        }
+    } else {
+        match core_generators::hexagonal_lattice_graph(
+            rows, cols, default_fn, default_fn, false, periodic,
+        ) {
+            Ok(graph) => graph,
+            Err(_) => return Err(PyValueError::new_err("Invalid arguments")),
+        }
+    };
     Ok(graph::PyGraph {
         graph,
         node_removed: false,
@@ -1162,6 +1193,12 @@ pub fn hexagonal_lattice_graph(
 ///     :class:`~rustworkx.PyDiGraph` object will not be not be a multigraph and
 ///     won't allow parallel edges to be added. Instead
 ///     calls which would create a parallel edge will update the existing edge.
+/// :param bool periodic: When set to ``True`` the boundaries of the lattice
+///     will be joined to form a periodic grid. Requires ``cols`` to be even,
+///     ``rows > 1``, and ``cols > 1``.
+/// :param bool with_positions: When set to ``True`` each node will be assigned
+///     a pair of coordinates ``(x, y)`` as a payload. This embeds the nodes in
+///     the plane so that each hexagon is regular (with side length 2).
 ///
 /// :returns: The generated directed hexagonal lattice graph.
 ///
@@ -1179,7 +1216,7 @@ pub fn hexagonal_lattice_graph(
 ///
 #[pyfunction]
 #[pyo3(
-    signature=(rows, cols, bidirectional=false, multigraph=true),
+    signature=(rows, cols, bidirectional=false, multigraph=true, periodic=false, with_positions=false),
 )]
 pub fn directed_hexagonal_lattice_graph(
     py: Python,
@@ -1187,17 +1224,36 @@ pub fn directed_hexagonal_lattice_graph(
     cols: usize,
     bidirectional: bool,
     multigraph: bool,
+    periodic: bool,
+    with_positions: bool,
 ) -> PyResult<digraph::PyDiGraph> {
     let default_fn = || py.None();
-    let graph: StablePyGraph<Directed> = match core_generators::hexagonal_lattice_graph(
-        rows,
-        cols,
-        default_fn,
-        default_fn,
-        bidirectional,
-    ) {
-        Ok(graph) => graph,
-        Err(_) => return Err(PyIndexError::new_err("rows and cols not specified")),
+    let graph: StablePyGraph<Directed> = if with_positions {
+        let node_position_fn =
+            |u: usize, v: usize| _hexagonal_lattice_node_position(u, v).to_object(py);
+        match core_generators::hexagonal_lattice_graph_weighted(
+            rows,
+            cols,
+            node_position_fn,
+            default_fn,
+            bidirectional,
+            periodic,
+        ) {
+            Ok(graph) => graph,
+            Err(_) => return Err(PyValueError::new_err("Invalid arguments")),
+        }
+    } else {
+        match core_generators::hexagonal_lattice_graph(
+            rows,
+            cols,
+            default_fn,
+            default_fn,
+            bidirectional,
+            periodic,
+        ) {
+            Ok(graph) => graph,
+            Err(_) => return Err(PyValueError::new_err("Invalid arguments")),
+        }
     };
     Ok(digraph::PyDiGraph {
         graph,


Test patch:
diff --git a/tests/generators/test_hexagonal.py b/tests/generators/test_hexagonal.py
index edce2da6c..6e09bb8c7 100644
--- a/tests/generators/test_hexagonal.py
+++ b/tests/generators/test_hexagonal.py
@@ -12,6 +12,7 @@
 
 import unittest
 import rustworkx
+import networkx
 
 
 class TestHexagonalLatticeGraph(unittest.TestCase):
@@ -415,3 +416,162 @@ def test_hexagonal_graph_0_0(self):
         graph = rustworkx.generators.hexagonal_lattice_graph(0, 0)
         self.assertEqual(len(graph), 0)
         self.assertEqual(len(graph.edges()), 0)
+
+    def test_hexagonal_graph_periodic_subgraphs(self):
+        """Check that hexagonal subgraphs of the lattice are isomorphic
+        to C6 (idea copied from the networkx test suite)."""
+        graph = rustworkx.generators.hexagonal_lattice_graph(2, 4, periodic=True)
+        hexagons = [[0, 1, 2, 6, 5, 4], [2, 3, 0, 4, 7, 6], [5, 6, 7, 11, 10, 9]]
+        C6 = rustworkx.generators.cycle_graph(6)
+        for h in hexagons:
+            self.assertTrue(rustworkx.is_isomorphic(graph.subgraph(h), C6))
+
+        # For a periodic graph with two columns, subgraphs are not necessarily
+        # isomorphic to C6 because of the boundary connections.
+        graph2cols = rustworkx.generators.hexagonal_lattice_graph(2, 2, periodic=True)
+        subGraph = graph2cols.subgraph(hexagons[0])
+        self.assertFalse(rustworkx.is_isomorphic(subGraph, C6))
+        self.assertEqual(len(subGraph.edges()), 7)
+
+    def test_hexagonal_graph_periodic_degree(self):
+        """In a periodic hexagonal lattice, all nodes must have
+        degree 3 (idea copied from the networkx test suite)."""
+        for nRows in range(2, 8):
+            for nCols in range(2, 8, 2):
+                graph = rustworkx.generators.hexagonal_lattice_graph(nRows, nCols, periodic=True)
+                for n in range(graph.num_nodes()):
+                    self.assertEqual(graph.degree(n), 3)
+
+    def test_hexagonal_graph_periodic_3_4(self):
+        graph = rustworkx.generators.hexagonal_lattice_graph(3, 4, periodic=True)
+        expected_edges = [
+            (0, 1),
+            (1, 2),
+            (2, 3),
+            (3, 4),
+            (4, 5),
+            (5, 0),
+            (6, 7),
+            (7, 8),
+            (8, 9),
+            (9, 10),
+            (10, 11),
+            (11, 6),
+            (12, 13),
+            (13, 14),
+            (14, 15),
+            (15, 16),
+            (16, 17),
+            (17, 12),
+            (18, 19),
+            (19, 20),
+            (20, 21),
+            (21, 22),
+            (22, 23),
+            (23, 18),
+            (0, 6),
+            (2, 8),
+            (4, 10),
+            (7, 13),
+            (9, 15),
+            (11, 17),
+            (12, 18),
+            (14, 20),
+            (16, 22),
+            (19, 1),
+            (21, 3),
+            (23, 5),
+        ]
+        self.assertEqual(len(graph), 24)
+        self.assertEqual(len(graph.edges()), len(expected_edges))
+        self.assertEqual(set(graph.edge_list()), set(expected_edges))
+
+    def test_directed_hexagonal_graph_periodic_3_2(self):
+        graph = rustworkx.generators.directed_hexagonal_lattice_graph(3, 2, periodic=True)
+        expected_edges = [
+            (0, 1),
+            (1, 2),
+            (2, 3),
+            (3, 4),
+            (4, 5),
+            (5, 0),
+            (6, 7),
+            (7, 8),
+            (8, 9),
+            (9, 10),
+            (10, 11),
+            (11, 6),
+            (0, 6),
+            (2, 8),
+            (4, 10),
+            (7, 1),
+            (9, 3),
+            (11, 5),
+        ]
+        self.assertEqual(len(graph), 12)
+        self.assertEqual(len(graph.edges()), len(expected_edges))
+        self.assertEqual(set(graph.edge_list()), set(expected_edges))
+
+    def test_directed_hexagonal_graph_bidirectional_periodic_3_2(self):
+        graph = rustworkx.generators.directed_hexagonal_lattice_graph(
+            3, 2, bidirectional=True, periodic=True
+        )
+        expected_edges = [
+            (0, 1),
+            (1, 0),
+            (1, 2),
+            (2, 1),
+            (2, 3),
+            (3, 2),
+            (3, 4),
+            (4, 3),
+            (4, 5),
+            (5, 4),
+            (5, 0),
+            (0, 5),
+            (6, 7),
+            (7, 6),
+            (7, 8),
+            (8, 7),
+            (8, 9),
+            (9, 8),
+            (9, 10),
+            (10, 9),
+            (10, 11),
+            (11, 10),
+            (11, 6),
+            (6, 11),
+            (0, 6),
+            (6, 0),
+            (2, 8),
+            (8, 2),
+            (4, 10),
+            (10, 4),
+            (7, 1),
+            (1, 7),
+            (9, 3),
+            (3, 9),
+            (11, 5),
+            (5, 11),
+        ]
+        self.assertEqual(len(graph), 12)
+        self.assertEqual(len(graph.edges()), len(expected_edges))
+        self.assertEqual(set(graph.edge_list()), set(expected_edges))
+
+    def test_hexagonal_graph_periodic_networkx_equivalent(self):
+        """Networkx uses different logic to construct a periodic hexagonal
+        lattice graph, so here we check that the results from rustworkx and
+        networkx are isomorphic for a few cases."""
+        for nRows in range(2, 8):
+            for nCols in range(2, 8, 2):
+                graph = rustworkx.generators.hexagonal_lattice_graph(nRows, nCols, periodic=True)
+
+                nx_graph = rustworkx.networkx_converter(
+                    networkx.hexagonal_lattice_graph(nRows, nCols, periodic=True)
+                )
+
+                self.assertTrue(rustworkx.is_isomorphic(graph, nx_graph))
+
+    def test_hexagonal_graph_periodic_odd_columns(self):
+        with self.assertRaises(ValueError):
+            rustworkx.generators.hexagonal_lattice_graph(4, 5, periodic=True)
diff --git a/tests/visualization/test_mpl.py b/tests/visualization/test_mpl.py
index 312c857f6..2208b4f3f 100644
--- a/tests/visualization/test_mpl.py
+++ b/tests/visualization/test_mpl.py
@@ -192,3 +192,15 @@ def test_labels_and_colors(self):
         )
         fig = plt.gcf()
         _save_images(fig, "test_labels_and_colors.png")
+
+    def test_hexagonal_lattice_undirected(self):
+        graph = rustworkx.generators.hexagonal_lattice_graph(3, 4, with_positions=True)
+        plt.close("all")
+        mpl_draw(graph, pos=[graph.get_node_data(n) for n in range(len(graph))])
+        _save_images(plt.gcf(), "test_hexagonal_lattice.png")
+
+    def test_hexagonal_lattice_directed(self):
+        graph = rustworkx.generators.directed_hexagonal_lattice_graph(3, 4, with_positions=True)
+        plt.close("all")
+        mpl_draw(graph, pos=[graph.get_node_data(n) for n in range(len(graph))])
+        _save_images(plt.gcf(), "test_hexagonal_lattice_directed.png")