Skip to content

Added new classes to return the edges of polyhedra #171

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Aug 16, 2023

Conversation

janbridley
Copy link
Collaborator

@janbridley janbridley commented Feb 6, 2023

Description

The new class polyhedron.edges returns the a list of the edges of a polyhedron as vertex-index pairs, similar to the current polyhedron.faces class. The class polyhedron.get_edge_vectors returns a list of edges as vectors in 3D space.

Types of Changes

  • Documentation update
  • Bug fix
  • New feature

Checklist:

b-butler and others added 2 commits January 25, 2023 18:30
Only test quaternions that are not effectively zero.
The new class ``polyhedron.edges`` returns the a list of the edges of a polyhedron as vertex-index pairs, similar to the current ``polyhedron.faces`` class. The class ``polyhedron.get_edge_vectors`` returns a list of edges as vectors in 3D space.
@janbridley janbridley requested a review from a team February 6, 2023 16:04
@codecov
Copy link

codecov bot commented Feb 13, 2023

Codecov Report

Merging #171 (341cd53) into master (e1253af) will decrease coverage by 0.06%.
The diff coverage is 47.05%.

@@            Coverage Diff             @@
##           master     #171      +/-   ##
==========================================
- Coverage   54.83%   54.78%   -0.06%     
==========================================
  Files          26       26              
  Lines        2604     2621      +17     
==========================================
+ Hits         1428     1436       +8     
- Misses       1176     1185       +9     
Files Changed Coverage Δ
coxeter/shapes/convex_polyhedron.py 48.21% <33.33%> (-0.85%) ⬇️
coxeter/shapes/polyhedron.py 63.83% <50.00%> (-0.64%) ⬇️

janbridley added a commit that referenced this pull request Feb 13, 2023
This branch was initially based on #171 - this is unnecessary and has been fixed.
Copy link
Contributor

@vyasr vyasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! I have some suggestions for improvement here.

edges = []
for face in self.faces:
[
edges.append((i, j))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an odd construct. You're using a list comprehension to mimic a for loop, but you're populating it with a bunch of None. Instead, you can do a nested list comprehension where you construct edges in the comprehension rather than appending to it, something like return [(i, j) for ... for face in self.faces.

Copy link
Collaborator Author

@janbridley janbridley Feb 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imageOption 2

Based on your above feedback, I have come up with these options. The first option is an improved version of the append structure in the previous commit, which implements your suggestion of a nested list comprehension. It takes approximately 86us per iteration, as tested with timeit and 10 million iterations.

The second uses two list comprehensions, one to generate the edges and their reverses, and one to create the final list without reverse elements. It takes approximately 96 microseconds per iteration, tested in the same manner as above.

Note that both methods were formatted with black, so the images above are the formatted structure of the code as written.

Although I tried to come up with a cleaner algorithm for this, I could not make one that (1) returns the edges in an order corresponding to the input faces and (2) was faster than the appending method. I would be happy to hear your input on this if you have any, and if you like either of the above methods I will commit them.

Copy link
Member

@bdice bdice Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid using a list comprehension if the result is thrown out -- all those edges.append calls are returning a list of [None, None, None, ...] which is unused. Just use a for loop instead of a comprehension in this case, because this logic depends on the current value of the list (if (j, i) not in edges). It's a bit faster (no allocation for the throwaway result) and more readable.

Secondly, we can make this faster if we avoid np.roll and "roll our own." 😉 For educational purposes, I wrote up a microbenchmark linked below. Try it out locally! For me, "option 3" is around 3x faster than the other implementations. That said, I would not choose the final implementation based solely on speed. Readability is important.

https://github.com/bdice/python_microbenchmarks/blob/main/microbenchmarks/faces_to_edges.py

(Another small request -- please write formatted snippets with ```python code blocks instead of screenshots. It makes it easier to read and propose alternatives.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vyasr I think the reason a plain list comprehension doesn't work is mentioned above -- the edges are being de-duplicated in a way that depends on the current state of the edge list, and a comprehension can't reference its own state.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup you're right I didn't account for the deduplication process needing to access the previously existing ones. A simple for loop is probably easiest alternative. I would definitely go for higher readability over reimplementing something like roll. coxeter is pretty suboptimal performance-wise anyway.

Copy link
Collaborator Author

@janbridley janbridley Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good - The roll1 function is impressive but I agree it is probably not the cleanest option.

(Another small request -- please write formatted snippets with ```python code blocks instead of screenshots. It makes it easier to read and propose alternatives.)

Noted - thank you for the information. The following code has been formatted as requested.

Option 1: most readable

def edges():
    return {
        (i, j) for face in self.faces for i, j in zip(face, np.roll(face, -1)) if i < j
    }

Option 2: fastest (by about an order of magnitude)

def edges():
  return{
      (face[i], face[(i + 1) % len(face)])
      for face in self.faces
      for i in range(len(face))
      if face[i] < face[(i + 1) % len(face)]
  }

As above, I think the more readable option 1 is the better choice for this package.

janbridley and others added 5 commits February 24, 2023 13:50
Until the precision improvements in #177 are added, a decrease in rtol and the merge_faces call are required for pytest to succeed on the dodecahedron's edges. Once the precision is increased, these temporary solutions can be reverted
@janbridley
Copy link
Collaborator Author

Looks like CircleCI is failing because of inaccuracies in the dodecahedron's stored data. I feel that we should wait for #177 to be merged before merging this branch (#171), so that the small fixes currently in test_edges can be reverted before this branch goes live. This will result in a cleaner merge.

…ed data

Currently, the stored data for polyhedra is not accurate enough for the ``edges`` and ``edge_vectors`` properties to function as expected. Once #177 is merged, the accuracy increase should fix the issue and assertion tolerances for testing can be tightened. In addition, ``merge_faces`` should no longer be required for any of the polyhedra included with this package.
def edges(self):
"""list(:class:`numpy.ndarray`): Get the polyhedron's edges.

Results returned as vertex index pairs.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs should mention that edges are de-duplicated and that pairs are unordered. However, I'm not really sure if that's what we want. I would think that users would prefer to have control of the result, and de-duplicate and/or determine symmetry (i, j) == (j, i) for themselves. Should edges just be return [(i, j) for f in self.faces for i, j in zip(f, np.roll(f, -1))]?

Copy link
Member

@bdice bdice Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see it either way. Both properties would be useful for different things. If you wanted to draw wireframes or make 3D models, you might need the full set of directed face edges (including "duplicates" belonging to different faces), but you can get that easily from the faces property. Perhaps de-duplicating and accounting for symmetry is the right move here.

Copy link
Collaborator Author

@janbridley janbridley Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My personal feeling is that the 'cleanest' output should be accessible directly from the package, and the (slightly) more complicated case can be done by the user. That being said, I agree with your sentiment that neither method is incorrect. @vyasr I would appreciate your input here as well!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@janbridley Can you also give context on your motivation for this PR? What does your use case require?

Copy link
Collaborator Author

@janbridley janbridley Feb 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also note that edges generated here are ordered: edges move clockwise around the surface of a face, and faces are ordered counterclockwise with respect to an outward normal. The is actually not the case, you are correct. The roll operation means that each face has at least one "jump" as edges are drawn. I will look at a method that better orders the edges before I update the docstring

Note that using j,i pairs reverses the direction of each vector, but keeps their clockwise ordering. I will update the docstring to clarify!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sets of sets would be the most Python-friendly way to indicate this that I can think of. However, you'll run into a problem: sets are mutable, and thus not hashable. You'd have to have a set of frozenset objects (immutable sets, which are hashable). We could return a NumPy array, sorted and with only pairs where i < j.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd advise against multiple properties unless we have a really clear use case. I'm still not sure if I fully understand what the intended use case is for this property. How you do want to use the edges property?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edges allows for the calculation of each component of the asphericity of a polyhedra, allowing for a determination of the relative contribution of each feature. Edge_vectors allows for the calculation of angles and distances between specific edges on a polyhedron, which can be useful for simulations of highly anisotropic particles. @Tobias-Dwyer if you have any contributions I'd love to hear them!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My personal feeling is that the 'cleanest' output should be accessible directly from the package, and the (slightly) more complicated case can be done by the user.

This is the right way to think about it. The package shouldn't be overloaded with a dozen variations on essentially the same property. From that perspective, given that as you've pointed out there are multiple possibilities for exactly what is required in different situations, would we be better served by including more documentation of how to compute edges in the documentation of the faces property? Otherwise we could easily proliferate multiple options based on considerations of ordering and uniqueness.

Copy link
Collaborator Author

@janbridley janbridley Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I feel that some variant of edges should be included with the package - along with vertices and faces, edges are a defining element of polyhedra, and the number of either (i,j) or (j,i) edges allows for the correct calculation of the Euler characteristic, an intrinsic property of polyhedra in general. I agree that cluttering the package is undesirable, but I feel that including documentation on how to calculate edges is no cleaner than just efficiently implementing that method.

Looking at other widely used geometry packages, it seems like selecting edges with (i<j) is probably the best way forward for Coxeter. Mathematica's PolyhedronData[] is ordered this way, and it is probably the least confusing approach. CGAL provides the Halfedgetrait for polyhedra, which is equivalent to our (i,j) or (j,i) pairs, and allow users to select the direction and ordering of edges. However, CGAL's Polyhedron_3 does not calculate edges for an entire polyhedra at once: rather, there are independent member functions to find local half-edges from a vertex, edge, or face on the surface. This approach is probably too verbose for Coxeter, but I am not opposed to renaming our method halfedges if that would avoid confusion.

Based on this, I think the best approach is probably to return a set of edges with (i<j) and each edge included once. The documentation will be updated to better describe the method, and I have included two options for the new code above.

@janbridley janbridley self-assigned this Mar 21, 2023
After discussing possible options for returning polyhedral edges, it was determined that a set of i<j pairs of vertices was the best option for output in the ``edges`` method. A description of how to generate (j,i) pairs was added to the docstring, and the edge_vectors method was updated to work with the new set/tuple structure.
It was determined that edges should be returned as an ordered Numpy array. This commit ensures the final output is a numpy array, sorted first by the (i) element and then by the (j) element where i<j. This mimics Mathematica's edges output format and should aid in usability. Documentation was updated accordingly.
for edge in poly.edge_vectors:
assert np.isclose(np.linalg.norm(edge), edgelength)

# Test edges property
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To test the edges property, I would consider some of its invariants / properties:

  • assert that the first column of i indices is sorted
  • assert that the the indices are ordered i < j
  • assert that the array has an integer dtype
  • etc.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 986fa8d

janbridley and others added 6 commits August 7, 2023 20:03
@janbridley janbridley marked this pull request as draft August 9, 2023 15:37
@janbridley janbridley marked this pull request as ready for review August 9, 2023 15:43
@janbridley janbridley requested a review from bdice August 9, 2023 15:55
@DomFijan
Copy link
Contributor

@bdice, any chance you can take a look at this before the end of Wednesday next week if you have the time? If not, I can jump in so we can finish this PR. There are a couple more tasks that depend on this that we want to start moving along. :)

Copy link
Member

@bdice bdice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small suggestions / verifications but otherwise LGTM. Feel free to merge once you are happy with it.

Credits.rst Outdated
@@ -112,6 +112,7 @@ Jen Bradley
* Added shape families for Archimedean, Catalan, and Johnson solids.
* Added shape family for prisms and antiprisms.
* Added shape family for equilateral pyramids and dipyramids.
* Added edges, edge_vectors, and num_edges methods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Added edges, edge_vectors, and num_edges methods
* Added edges, edge_vectors, and num_edges methods.

[
[i, j]
for face in self.faces
for i, j in zip(face, np.roll(face, -1))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just making sure — the face indices are ordered consistently, right? In other words, this code relies on face indices being sorted in such a way that any shared edge is in the order (i, j) for one face and (j, i) for the other face sharing that edge. Is that a safe assumption from the rest of the code (it has been a while since I’ve read it). If this isn’t handled properly by the construction of the shape, then you’ll get missing edges. Are there any degenerate cases where this doesn’t happen automatically (can a polyhedron have one face in coxeter)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure exactly what the limits of Polyhedron are: for example, if self-intersecting shapes, shapes with holes, etc work properly. That being said, faces are ordered and edges are built using another method during initialization so it must work for the vast majority of use cases! I can take a deeper look in the next few days.

Copy link
Collaborator Author

@janbridley janbridley Aug 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex polyhedra (within limits) seem to work - self intersecting polyhedra, polyhedra with holes, and simple non convex (e.g. stellated) polyhedra all compute edges as expected. However, there are cases where volumes, moments of inertia, and other properties are incorrect. There are some cases where edges does NOT work - but in these cases, many other methods break down. Polygons (one-face polyhedron), dihedra, and nonclosed solids tend to break this method but they also return null values for any method that includes volume. Some also have erroneous behavior in the centroid or inertia tensor calculation. I will create an issue for some of these edge cases in general, but overall the new edge methods are at least as robust as the other core methods.

Copy link
Member

@bdice bdice Aug 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good. It’s good to know the limitations — and which cases you are willing to treat as undefined behavior vs. raising an error or “fix” in some way.

# Check that there are no duplicate edges. This also double-checks the sorting
assert np.all(np.unique(poly.edges, axis=1) == poly.edges)

# Check that the edges are immutable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simpler if you want. I would just use pytest’s “assert raises” context manager, wrapping the assignment.

janbridley and others added 2 commits August 11, 2023 18:47
Co-authored-by: Bradley Dice <[email protected]>
Co-authored-by: Bradley Dice <[email protected]>
@DomFijan
Copy link
Contributor

@bdice thanks for the prompt reaction :)

@janbridley janbridley enabled auto-merge (squash) August 15, 2023 01:40
@janbridley janbridley requested a review from vyasr August 15, 2023 03:19
@janbridley janbridley disabled auto-merge August 16, 2023 00:01
@janbridley janbridley merged commit 6e5331d into master Aug 16, 2023
@janbridley janbridley deleted the release-edgelengths branch August 16, 2023 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants