Skip to content
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

Unify stage coordinates' units across instamatic #109

Merged
merged 13 commits into from
Feb 12, 2025

Conversation

Baharis
Copy link
Contributor

@Baharis Baharis commented Feb 4, 2025

The context

Currently, instamatic uses multiple different types to handle stage position variables. Stage length coordinates x, y, z are sometimes expressed as int in nanometers (Jeol microscope), sometimes as float in microns (FEI microscopes); sometimes, the same values are also supplied as neither int nor float but rather their numpy counterparts. This is especially harmful for a server without numpy that is unable to convert numpy.int32 to int. Similarly, even though documentation suggests the angles should always be instances of int (though this is also not very clear), in numerous places the angles are instances of float:

From src/instamatic/microscope/interface/fei_microscope.py, FEIMicroscope lines 148–156 — micron distances, float degrees:

    def getStagePosition(self):
        """Return numbers in microns, angles in degs."""
        return (
            self.stage.Position.X * 1e6,
            self.stage.Position.Y * 1e6,
            self.stage.Position.Z * 1e6,
            self.stage.Position.A / pi * 180,
            self.stage.Position.B / pi * 180,
        )

From src/instamatic/microscope/interface/jeol_microscope.py, JeolMicroscope lines 282–285 — nanometer distances, int degrees:

    def getStagePosition(self) -> Tuple[int, int, int, int, int]:
        """X, y, z in nanometer a and b in degrees."""
        x, y, z, a, b, result = self.stage3.GetPos()
        return x, y, z, a, b

From src/instamatic/controller.py, find_eucentric_height lines 404–414 (excerpt) — nanometer numpy.int_ stage.z:

        zc = self.stage.z
        print(f'Current z = {zc:.1f} nm')
        zs = zc + np.linspace(-dz, dz, steps)
        for i, z in enumerate(zs):
            self.stage.z = z

This PR aims to unify the units used to express the position of microscope stage across instamatic. To this aim, it firstly introduces a small module instamatic.typing with two new Annotated types: int_nm and float_deg. These types are thin wrappers around int and float i.e. type(int_nm(1.0)) == int. The sole purpose of the new annotated wrappers is to display the unit in type hints in addition to the comments. The only difference between int and int_nm is an annotation that appears next to the later in the integrated development environment:

image

int_nm and float_deg are just type hints; in order to enforce actual change, some interface methods, including these passed to stage.get and stage.set, have been modified to now always accept and return:

  • x, y, z as annotated type int_nm, actual type int
  • a, b as annotated type float_deg, actual type float

Major changes

  • FEIMicroscope and FEISimuMicroscope's get/setStagePosition now communicate distances in nanometers instead of microns, making their behavior consistent with the rest of Instamatic.

Minor changes

  • Whenever there is a room for any ambiguity, x/y/z & a/b are now hinted to be of int_nm & float_deg type;
    • These new types are just thin Annotated wrappers around int and float and affect only type hinting;
    • In python 3.7 and 3.8 where Annotated is not available, they are just aliases of (equal to) int and float.
  • setStagePosition input x/y/z/a/b input is now correctly marked as typing.Optional;
  • getStagePosition and setStagePosition methods now return an instance of StagePositionTuple.
  • StagePositionTuple was moved to a new src/instamatic/microscope/typing.py to remove risk of circular imports and allow use as type hints;
  • When expressed in nanometers, distances are printed using z = {zc} nm instead of z = {zc:.1f} nm as the trailing decimal is irrelevant (always zero).
  • Minor improvements to microscope implementations' and stage's doc-strings and type-hints.

Bug fixes

  • Fixed issue introduced in Tecnai bigfixes, Microscope client documentation and EAFP #108 where microscope.components.stage:Stage.set could raise TypeError if supplied only partial optional input due to use of round.
  • In microscope.client.py:MicroscopeClient._eval_dct, change hint type from dict to Dict to comply with python 3.7.

Effect on the codebase

This change might be breaking for existing script. If there are any scripts designed and used on FEI machines only that use hard-coded distances expressed in microns, they will need to be updated to utilize nanometers instead. However, with this step they should become a step closer to be fully interoperable between machines.

To the best of my understanding the change of units in FEI should have no tangible effect on Joel microscopes whatsoever.

Compatibility note

This PR is accompanied by and should be merged alongside PR #2 of Instamatic Tecnai Server.

@Baharis
Copy link
Contributor Author

Baharis commented Feb 4, 2025

@stefsmeets I plan to go through existing uses of the stage component and try to gauge where it was designed with FEI's microns rather than Jeol's nanometer in mind. Still, do you know if there are any modules, commands, or scripts that were developed strictly for or by FEI groups?

Copy link
Member

@stefsmeets stefsmeets left a comment

Choose a reason for hiding this comment

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

Lovely work, didn't know you could do this with types. I'm not aware of any specific bits that use the 'FEI' way, mostly the code converts to the same scale in the microscope class.

Have you managed to get this to work in mypy? We should consider adding that as a test if we are adding more and more types to ensure that the types are actually enforced and checked.

I left some comments in the code to clean up some of the types.

Let me know once it is ready and I'll happily merge this.

Comment on lines 254 to 255
x: int_nm = None,
y: int_nm = None,
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
x: int_nm = None,
y: int_nm = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,

Comment on lines 162 to 166
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
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
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,
z: Optional[int_nm] = None,
a: Optional[float_deg] = None,
b: Optional[float_deg] = None,

Comment on lines 248 to 252
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
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
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,
z: Optional[int_nm] = None,
a: Optional[float_deg] = None,
b: Optional[float_deg] = None,

Comment on lines 269 to 273
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
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
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,
z: Optional[int_nm] = None,
a: Optional[float_deg] = None,
b: Optional[float_deg] = None,

Comment on lines 338 to 342
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
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
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,
z: Optional[int_nm] = None,
a: Optional[float_deg] = None,
b: Optional[float_deg] = None,

Comment on lines 430 to 434
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
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
x: int_nm = None,
y: int_nm = None,
z: int_nm = None,
a: float_deg = None,
b: float_deg = None,
x: Optional[int_nm] = None,
y: Optional[int_nm] = None,
z: Optional[int_nm] = None,
a: Optional[float_deg] = None,
b: Optional[float_deg] = None,

@Baharis
Copy link
Contributor Author

Baharis commented Feb 6, 2025

@stefsmeets Addressing individual issues:

  • I do like your suggestion to add Optional for typing wherever it is applicable. I will do it in a separate commit to make sure all imports are in place.
  • I just checked some files with mypy and currently the lack of Optional is the only issue found, Annotated are understood correctly.
  • I do not have experience with mypy tests; this PR has, however, started from my trying to "enforce" the types using a wrapper class rather than just hinting at units – once I got it semi-functional I realized it is opaque and restrictive, hence I decided to go with very explicit type hints rather than opaque wrappers.
  • Regarding StagePositionTuple, I thought about using it as output of individual interfaces but thought this might be too big of a difference... I think it would fit better though, so I will suggest adding it.
  • I am opposed to switching from NamedTuple to @dataclass here though: dataclass cannot be directly iterated or indexed, so returning a dataclass will be slower and will require a lot of code changes in applications IMO. Do not get me wrong, I do love dataclasses, but I think their strength lie elsewhere :).

PS: Some examples of scripts that would need rewriting if changed to dataclass:

instamatic/calibrate/center_z.py, L126:

x0, y0, z0, a0, b0 = ctrl.stage.get()

instamatic/experiments/autocred/experiment.py, L1203 (and other):

    xpoint, ypoint, zpoint, aaa, bbb = self.ctrl.stage.get()
    self.logger.info(
        f'Stage position: x = {xpoint}, y = {ypoint}. Z height adjusted to {zpoint}. Tilt angle x {aaa} deg, Tilt angle y {bbb} deg'
    )

cred, cred-gatan, cred-tvips, serialed all also use the "tuple unpacking" mechanism that would require additional dataclass-to-tuple constructors.

@Baharis Baharis marked this pull request as ready for review February 10, 2025 11:08
@Baharis
Copy link
Contributor Author

Baharis commented Feb 10, 2025

I took the liberty to modify tests/test_ctrl.py:test_stage since in two places it expected x/y/z to be equal sqrt(2) and consequently of float type. With the stage rewrite, these coordinates now are coerced into int (even for simulation), hence the smallest distance each of them can express is 1 nm. This is definitely not an issue for our Tecnai since it has a precision of ~50 nm at best, hopefully will not be an issue for Jeol as well.

@stefsmeets
Copy link
Member

Looks good, give me a tag when this PR is ready and I'll pull the trigger on both this and instamatic-dev/instamatic-tecnai-server#2

@Baharis
Copy link
Contributor Author

Baharis commented Feb 11, 2025

@stefsmeets I have ran it on my microscope and had no issues, I think it is ready from my point of view. I had some time to look at the PR "from a distance" and my newfound concern is that one feature of this PR that is not highlighted is that x/y/z now MUST be integers. Based on the hints and comments, I think this was always supposed to be the case, but I am concerned that in many places (such as in tests) x/y/z were used as floats, and forcing them into ints might have some unexpected consequences. I would really love to hear your opinion on that before merging (@viljarjf ?), especially since coercing x/y/z info floats is just as good from my perspective and might avoid these issues. What do you think?

Edit: Benefits of floats over int: bigger precision, better handling for simulation, future proofing. Benefits of int over float: easier presentation, consistency, comparisons, simplicity.

@stefsmeets
Copy link
Member

I have very little skin in the game anymore. On the JEOL it worked with ints, and Python converts to float on the fly if necessary. If it works on the Tecnai that is good enough for me. Happy to wait for a thumbs up from @viljarjf though.

The tests were made with a hypothetical scenario in mind, the numbers/types are meaningless, it's just checking whether the move in projection/along the optical axis is numerically correct.

@Baharis
Copy link
Contributor Author

Baharis commented Feb 11, 2025

@stefsmeets Thank you, that is good to hear. Well, regarding experience, I still have very little of it and I don't think the state of hardware has changed drastically in the last few years. Just to be sure, I also asked a colleague in the lab who suggests that the sphere of confusion for most goniometers is still ~over 300 nm and recent experiments with significantly more precise piezo-stages were not successful so it is likely x/y/z might stay integers for quite some time.

@viljarjf
Copy link
Contributor

Nice work! I had something like this in my backlog too.
For simulations, there is nothing stopping us from storing the position internally as a float, so long as the returned values are of the correct type, similar to the microscope interfaces. I am happy to keep them as ints.

@stefsmeets
Copy link
Member

I also asked a colleague in the lab who suggests that the sphere of confusion for most goniometers is still ~over 300 nm and recent experiments with significantly more precise piezo-stages were not successful so it is likely x/y/z might stay integers for quite some time.

Yeah, this is true. It is impossible to move such small amounts. I played with the piezo stage many years ago, and even that is not 1 nm precision.

I'm going to merge this PR, thanks for the nice work @Baharis !

@stefsmeets stefsmeets merged commit 0a3febd into instamatic-dev:main Feb 12, 2025
7 checks passed
@Baharis Baharis deleted the common_units branch February 13, 2025 12:15
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.

3 participants