Skip to content

Tidy up circularity testing of reciprocal function pairs#2106

Open
CoePaul wants to merge 1 commit into
mainfrom
dodal2102
Open

Tidy up circularity testing of reciprocal function pairs#2106
CoePaul wants to merge 1 commit into
mainfrom
dodal2102

Conversation

@CoePaul

@CoePaul CoePaul commented Jun 24, 2026

Copy link
Copy Markdown
Contributor
  • For unary operator functions acting on float inputs like conversions in both directions between one unit and another

  • This replaces separately rolled once per test circularity with use of a common test circularity framework class

Fixes #2102

Instructions to reviewer on how to test:

  1. Confirm CI tests all pass
  2. See that You're happy as a reviewer with the separation out of circularity testing into its own class (ancilliary to testing)

Checks for reviewer

  • Would the PR title make sense to a scientist on a set of release notes
  • If a new device has been added does it follow the standards
  • If changing the API for a pre-existing device, ensure that any beamlines using this device have updated their Bluesky plans accordingly
  • Have the connection tests for the relevant beamline(s) been run via dodal connect ${BEAMLINE}

@CoePaul CoePaul requested a review from a team as a code owner June 24, 2026 12:33
@CoePaul CoePaul requested review from jacob720 and rtuck99 June 24, 2026 12:34

@oliwenmandiamond oliwenmandiamond left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think is a good idea, but looks to be overkill. You can simplify this a lot and add it into a utils.py function inside tests/common/general_maths so more helpful functions to assist with testing could be added as well.

Comment on lines +2 to +21
from collections.abc import Callable

import pytest


class FunctionalCircularityTester:

def __init__(self, forward_func: Callable[[float],float], reverse_func: Callable[[float],float]):
self.f = forward_func
self.g = reverse_func


def verify_forwards_circulation_returns_to_same_value(self, x: float):
conversion = self.f(x)
assert self.g(conversion) == pytest.approx(x)


def verify_reverse_circulation_returns_to_same_value(self, x: float):
conversion = self.g(x)
assert self.f(conversion) == pytest.approx(x)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is overkill. I think a better solution would be to make it a util function

def assert_roundtrip(forward, reverse, x):
    assert reverse(forward(x)) == pytest.approx(x)
    assert forward(reverse(x)) == pytest.approx(x)

Then you could use it like this:

def test_circular_mm_cm_interconversions():
    for x in [1, 2, ...]: 
        assert_roundtrip(convert_cm_to_mm, convert_mm_to_cm, x)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't see why making a class is such a heavy weight cost - there is no over kill.
If everybody in the group had to pay 10 quid every time a class was used then fair.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

also note Your suggested util function is really two functions in one ( but doesn't say that on the tin )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So your tests are always doing this

t = FunctionalCircularityTester(f, g)
t.verify_forwards_circulation_returns_to_same_value(x)
t.verify_reverse_circulation_returns_to_same_value(x)

Why do that when simply do this:

assert_roundtrip(f, g, x)

It's a lot more concise and is the point of a util function for testing logic works.

The name can be whatever you want. Maybe assert_forward_reverse_func

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I see Your points and counter with these advantages of the OO assuming immutable classes:

Encapsulation of Behavior: Instead of just grouping data, you are grouping the mathematical contract alongside the functions. The object represents the concept of a "Reciprocal Pair" itself.

Contextual Clarity: In your test file, writing pair = ReciprocalPair(f, g) explicitly names the relationship between f and g. The code reads as: "This object is a Reciprocal Pair, and I am asking it to verify its integrity at point x."

Thread Safety / Zero Side Effects: Because it's immutable, you can instantiate it once at the top of a test module and safely share it across dozens of different test conditions without fear of leaks.

Where the Rub Lies (The "Pythonic" Trade-Off)

The reason many Python developers default to the alternative functional approach isn't because immutability is bad—it’s because of Python's language syntax overhead.

In languages like Scala or Kotlin, immutable classes are incredibly lightweight. In Python, creating a class often requires writing boilerplate (init, type hints, etc.) or importing helper modules (dataclasses, NamedTuple) just to achieve true immutability.
The Verdict under Immutable OO

If your architectural goal is clean, expressive domain modeling where behaviors belong strictly to the nouns they describe, Version 2 is absolutely the better step. It elevates f and g from two random functions passing through a utility pipe into a cohesive, immutable mathematical domain concept.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I reply to "Why do that when simply do this"

why call "some test line on (x)" <-- one argument

vs

do-everything-in-one-kitchen-sink(f,g,x) <-- three arguments and now the reader has to think about each

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In a perfect world all methods would only ever take zero or one argument
code would be a lot longer
but each line would be very readable

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@oliwenmandiamond: I think Your original points did validly highlight the ambiguities inherent in my earlier stab at this. I've tried to improve the clarity of purpose across the latest update

@CoePaul CoePaul force-pushed the dodal2102 branch 2 times, most recently from 08a66cb to 38784fd Compare June 24, 2026 13:40
@codecov

codecov Bot commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.16%. Comparing base (b3d163a) to head (0127732).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2106   +/-   ##
=======================================
  Coverage   99.16%   99.16%           
=======================================
  Files         347      347           
  Lines       13585    13585           
=======================================
  Hits        13471    13471           
  Misses        114      114           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@CoePaul

CoePaul commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

also the suggested change is tantamount to where we started

@CoePaul CoePaul requested a review from oliwenmandiamond June 25, 2026 14:17
@CoePaul CoePaul force-pushed the dodal2102 branch 2 times, most recently from 6cf5d2b to d71a407 Compare June 25, 2026 14:27
Comment on lines +65 to +100
@pytest.mark.parametrize(
"function_pair, numerical_args",
[
(
ReciprocalFunctionPair(convert_ev_to_kev, lambda k: k * 1000.0),
[16.83, 0.0, 0.037, 1.0, 6.208, 18, 12345.6, 28906.4],
),
(
ReciprocalFunctionPair(convert_mm_to_cm, convert_cm_to_mm),
[-16.83, 0.0, 0.037, 1.0, 6.208, 18, 102.99],
),
(
ReciprocalFunctionPair(
convert_microns_to_cm,
lambda x: convert_cm_to_mm(convert_mm_to_microns(x)),
),
[-6.119, 0.0, 0.764, 1.02, 62.45, 12754, 3154.59],
),
(
ReciprocalFunctionPair(convert_microns_to_mm, convert_mm_to_microns),
[-12.38, 0.0, 0.307, 1.0, 6.45, 24, 231.089],
),
(
ReciprocalFunctionPair(
convert_factor_to_percentage, convert_percentage_to_factor
),
[0.0, 1.0, 0.5, 0.367, 27.404, 100.0, 99.8, 53.647],
),
],
)
def test_sanity_check_reciprocal_function_pairs(
function_pair: ReciprocalFunctionPair, numerical_args: list[float]
):
for x in numerical_args:
assert x == pytest.approx(function_pair.convert_then_invert(x))
assert x == pytest.approx(function_pair.invert_then_convert(x))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@pytest.mark.parametrize(
    "forward, reverse, numerical_args",
    [
        (
            convert_ev_to_kev,
            lambda k: k * 1000.0,
            [16.83, 0.0, 0.037, 1.0, 6.208, 18, 12345.6, 28906.4],
        ),
        (
            convert_mm_to_cm,
            convert_cm_to_mm,
            [-16.83, 0.0, 0.037, 1.0, 6.208, 18, 102.99],
        ),
        (
            convert_microns_to_cm,
            lambda x: convert_cm_to_mm(convert_mm_to_microns(x)),
            [-6.119, 0.0, 0.764, 1.02, 62.45, 12754, 3154.59],
        ),
        (
            convert_microns_to_mm,
            convert_mm_to_microns,
            [-12.38, 0.0, 0.307, 1.0, 6.45, 24, 231.089],
        ),
        (
            convert_factor_to_percentage,
            convert_percentage_to_factor,
            [0.0, 1.0, 0.5, 0.367, 27.404, 100.0, 99.8, 53.647],
        ),
    ],
)
def test_sanity_check_reciprocal_function_pairs(
    forward: Callable[[float], float], reverse: Callable[[float], float], numerical_args: list[float],
):
    for x in numerical_args:
        assert_forward_reverse(forward, reverse, x)

The current ReciprocalFunctionPair implementation stores the two functions and then exposes several wrapper methods that ultimately just call those functions in different orders. Unless we expect to attach additional metadata or behaviour to these pairs in future, I don't see a strong benefit to introducing the extra abstraction.

Comment on lines +4 to +67
class ReciprocalFunctionPair:
"""Ancilliary class which pairs together reciprocal functions for closed-loop testing.

Converting the numerical part of a cm quantity, for example, into the equivalent numerical part,
for an inches quantity and then converting back again should result in the number you first started with.
Such a closed loop is a circularity test.
Naturally it has two possible directions - so both are covered.

N.B. Only useful for testing monotonic functions that can only derive an output from one specific input;
something like squaring and sqrt would not be suitable since -4 -> 16 -> +4 does not close the loop
back to the original input.

Attributes:
forward_func: One of the pair of maths functions, assumed to take a number and return a number.
inverse_func: The inverse maths functions, assumed to take a number and return a number.
"""

def __init__(
self,
forward_func: Callable[[float], float],
inverse_func: Callable[[float], float],
):
"""Initializes the FunctionalCircularityTester with a specific pair of functions to test.

Args:
forward_func: A unary operator taking a number and returning a number.
inverse_func: A unary operator taking a number and returning a number.
"""
self.f = forward_func
self.g = inverse_func

def convert_then_invert(self, x: float):
"""Exercises the circular loop of applying first the forward function then its inverse.

Args:
x: a suitable numerical argument which should be compatible with the functions tested.
"""
conversion = self._convert(x)
return self._invert(conversion)

def invert_then_convert(self, x: float):
"""Exercises the circular loop of applying first the reverse function then the forward.

Args:
x: a suitable numerical argument which should be compatible with the functions tested.
"""
inversion = self._invert(x)
return self._convert(inversion)

def _convert(self, x: float):
"""Performs the forward operation (as defined in the constructor call.

Args:
x: a suitable numerical argument which should be compatible with the functions tested.
"""
return self.f(x)

def _invert(self, x: float):
"""Performs the inverse operation (as defined in the constructor call.

Args:
x: a suitable numerical argument which should be compatible with the functions tested.
"""
return self.g(x)

@oliwenmandiamond oliwenmandiamond Jun 25, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I am not sure how five methods of a class is better than a simple assert function which achieves the same thing.

def assert_forward_inverse(forward: Callable[[float], float], inverse: Callable[[float], float], x: float):
    """Assert that two functions are mutual inverses for a given input.

    Verifies that applying the forward function followed by the inverse
    returns the original value, and vice versa, within pytest's floating
    point tolerance.

    Args:
        forward: Function mapping a float to a float.
        inverse: Function expected to be the inverse of ``forward``.
        x: Test value to round-trip through both functions.
    """
    assert reverse(forward(x)) == pytest.approx(x)
    assert forward(inverse(x)) == pytest.approx(x)

I can see the value of the class if we expect to add additional metadata or behaviour to these function pairs in future, but based on the current usage it feels unnecessary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  • help me get round my mental block:
    I'm struggling to avoid reading the review as
    "You don't have permission to write declarative code that will aid tests - because
    there's no compelling business case for doing so"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I've requested changes because I agree on the use case but don't agree on the implementation. As this is a general util useful for testing for math function, in theory anyone else should be able to import and use but that isn't possible at the moment. My aim was in the style of ophyd-async assert functions e.g assert_reading, assert_value, assert_configuration, assert_emitted etc with some more examples from dodal.

Thoughts @jacob720 and @rtuck99? I'll let you tip the scales on this PR.

@CoePaul CoePaul Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I originally presented a solution that " anyone else should be able to import and use "
because I widely expect that - especially in green field projects like dodal ( but even secretly in legacy code ).

Once the earlier submissions were shot-down for being "unnecessary because we're not going to use it much"
I took it back out the of the way.

A further update sees the universal accessibility restored.

I do fail to see why using a class is objectionable. OO is a thing - even a good thing.
Use it or lose it.

@CoePaul CoePaul requested a review from oliwenmandiamond June 25, 2026 21:18
@CoePaul CoePaul force-pushed the dodal2102 branch 3 times, most recently from 82a5613 to 3e4d0d5 Compare June 26, 2026 16:37
* A circularity test acts as a functional sanity check.
   Each test acts on pairs of unary operator type functions which reciprocally convert float inputs
   (typically for the two directions of quantity conversion between two similar units
    Celsius <--> Fahrenheit could hypothetically be a valid example)

* Replace sanity checks that were written from scratch independently
   with a set-once use-again, mutually-inverse function pairing

* N.B. The use of a class for the pair rather than a 3 argument util function,
    places the two operator functions on a clearly equal footing,
    distinct from the arbitarily valued numerical probe argument used in verfication;
    whereas a util function muddies the waters, forcing cognitive load back onto the human reader.

Fixes #2102
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve conversion tests including circularity functionality checks in dodal / general maths

2 participants