Skip to content

Conversation

@CoePaul
Copy link
Contributor

@CoePaul CoePaul commented Oct 22, 2025

  • Add attenuator motor squad class to represent a set of mutually cooperating motors which are not co-mounted ( therefore do not form a physical stage ); but rather act in concert

  • Add initimately related position demand class used as driving argument to command motor squad to alter the posture of a beamline's transmission system

  • Add tests for both new classes: AttenuatorMotorPositionDemands AttenuatorMotorSquad

  • Adds access controlled subdirectory in the i19 device tests (to match the src devices file system layout)

  • Moves established test class for access controlled I19 shutters into the access controlled subdirectory

#1384

Instructions to reviewer on how to test:

  1. Continues PR 1652 in a new branch
  2. Confirm all tests pass

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 October 22, 2025 13:33
@CoePaul CoePaul requested a review from noemifrisina October 22, 2025 13:33
@codecov
Copy link

codecov bot commented Oct 22, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.09%. Comparing base (2b7fa79) to head (7267283).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1654   +/-   ##
=======================================
  Coverage   99.09%   99.09%           
=======================================
  Files         275      276    +1     
  Lines       10129    10158   +29     
=======================================
+ Hits        10037    10066   +29     
  Misses         92       92           

☔ View full report in Codecov by Sentry.
📢 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.

@model_validator(mode="after")
def no_keys_clash(self) -> Self:
common_key_filter = filter(
lambda k: k in self.continuous_demands, self.indexed_demands
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this doesn't do what you expect, comma operator doesn't exist in python, so

"a" in {"a":1,"b":2,"c":3}, {"b":2}
Out[17]: (True, {'b': 2})

Probably more concise to do

self.continuous_demands.keys() ^ self.indexed_demands.keys()

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that's a surprise - I was sure I had tested this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wll check it and get back to You

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it does do what it means to do, it's just confusingly formatted. lambda k: k in self.continuous_demands, self.indexed_demands are two arguments to the filter function, not a single lambda trying to look up a key in two dicts.

Either way it would be simpler to use & to get the common keys so the whole function becomes

if common := self.continuous_demands.keys() & self.indexed_demands:
    raise ValueError(f"duplicate key(s) found: {common}")
return self

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, yes you are right, I was suckered by the line break.

common_key_filter = filter(
lambda k: k in self.continuous_demands, self.indexed_demands
)
common_key_count = sum(1 for _ in common_key_filter)
Copy link
Contributor

Choose a reason for hiding this comment

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

if you had done a for-comprehension instead of using filter then you could just do
if filtered_list:

Alternatively, since you have an iterator, you could do
next(common_key_filter, False), that way you don't have to iterate through the whole set (although this is not going to make any practical performance difference in this case).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if common_key_count < 1:
return self
else:
ks: str = "key" if common_key_count == 1 else "keys"
Copy link
Contributor

Choose a reason for hiding this comment

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

It may be more useful to return the set intersection in the error message than the number?

# Value here vould be request params dictionary.
request_params = json.dumps(value)

async with ClientSession(base_url=self.url, raise_for_status=True) as session:
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, why does dodal use both requests and aiohttp????

Copy link
Contributor Author

@CoePaul CoePaul Oct 23, 2025

Choose a reason for hiding this comment

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

Might that be because this is not a real device - it's a rest call factory which is in turn calling (under guard) the real device elsewhere - or is that completely unrelated?

Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated I think. As far as I'm aware requests doesn't support async calls so something else was needed. There is also httpx that supports both that we looked at switching to at one point (DiamondLightSource/blueapi#1069)

# Value here vould be request params dictionary.
request_params = json.dumps(value)

async with ClientSession(base_url=self.url, raise_for_status=True) as session:
Copy link
Contributor

Choose a reason for hiding this comment

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

should we really be creating a new session for every request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think You're reviewing code which is already merged here - because it was too late to review it before?

Copy link
Contributor

Choose a reason for hiding this comment

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

If it seems relevant, I will review it!

position_demands: AttenuatorMotorPositionDemands = given_position_demands()
await motors.set(position_demands) # when motor position demand is set

expected_hutch: str = invoking_hutch.value
Copy link
Contributor

Choose a reason for hiding this comment

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

Since all of these things are only used once I think it would have been clearer just to inline them all into a single expected_request

Copy link
Contributor Author

Choose a reason for hiding this comment

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

When is a do-it-all-in-big-heap ever clearer than break it down into individual baby steps?
That makes no sense whatsoever

For details see the architecture described in \
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
"""
Copy link
Contributor

Choose a reason for hiding this comment

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

what are the concurrency semantics of this device? Is it expected that on completion of this set, that all motors will have completed their move? Or is it just that the move will have been requested, in which case how do we monitor for completion?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here for now the move is just fire and forget requested.
We have fast and slow motors and the overall move may take many seconds - we don't want to block.

Changes in motor position will fire listener events in the caller ( GDA for now ) and when the overall system
is interested - it wll check the system transmission and how far we are from the requested transmission.

Any time out for that event driven listening wait is independent of dodal for now.
( ie it's up to the caller to decide how patient it wants to be )

f"""Session PUT responded with {response.status}: {response.reason}.
Unable to run plan {value["name"]}.""" # type: ignore
)
return
Copy link
Contributor

Choose a reason for hiding this comment

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

If we fail to set the worker task as active, then this effectively returns and the Status will be successful

)
with pytest.raises(ClientConnectionError):
restful_response: AsyncMock = given_an_unhappy_restful_response()
unsuccessful_post.return_value.__aenter__.return_value = restful_response
Copy link
Contributor

Choose a reason for hiding this comment

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

This test doesn't fail in the expected way because you haven't mocked the PUT following the POST.
aiohttp raises ClientConnectionError because it does actually attempt a connect, but the POST error status is never evaluated.

Copy link
Contributor

Choose a reason for hiding this comment

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

There probably needs to be a few additional test cases here to check the various combinations of ways in which the HTTP requests can fail

)

try:
data = await response.json()
Copy link
Contributor

Choose a reason for hiding this comment

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

This response HTTP status code is never checked

Comment on lines +39 to +48
""" I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
Sends REST call to blueapi controlling optics on the I19 cluster.
The hutch in use is compared against the hutch which sent the REST call.
Only the hutch in use will be permitted to execute a plan (requesting motor moves).
As the two hutches are located in series, checking the hutch in use is necessary to \
avoid accidentally operating optics devices from one hutch while the other has beam time.
The name of the hutch that wants to operate the optics device is passed to the \
access controlled device upon instantiation of the latter.
For details see the architecture described in \
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
"""
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
""" I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
Sends REST call to blueapi controlling optics on the I19 cluster.
The hutch in use is compared against the hutch which sent the REST call.
Only the hutch in use will be permitted to execute a plan (requesting motor moves).
As the two hutches are located in series, checking the hutch in use is necessary to \
avoid accidentally operating optics devices from one hutch while the other has beam time.
The name of the hutch that wants to operate the optics device is passed to the \
access controlled device upon instantiation of the latter.
For details see the architecture described in \
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
"""
"""
I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
Sends REST call to blueapi controlling optics on the I19 cluster.
The hutch in use is compared against the hutch which sent the REST call.
Only the hutch in use will be permitted to execute a plan (requesting motor moves).
As the two hutches are located in series, checking the hutch in use is necessary to
avoid accidentally operating optics devices from one hutch while the other has beam time.
The name of the hutch that wants to operate the optics device is passed to the
access controlled device upon instantiation of the latter.
For details see the architecture described in
https://github.com/DiamondLightSource/i19-bluesky/issues/30.
"""

Trying to join lines with \ results in the docstring having weird whitespace issues

)
raise ValueError(error_msg)

def restful_format(self) -> dict[PermittedKeyStr, Any]:
Copy link
Contributor

Choose a reason for hiding this comment

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

restful_format is a bit meaningless given rest doesn't specify a format. Maybe combined or merged or all_demands

Suggested change
def restful_format(self) -> dict[PermittedKeyStr, Any]:
def all(self) -> dict[PermittedKeyStr, Any]:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Whatever it's called - I want it to be blatantly "Thing needed because REST needs something"
rather than "What the heck is this for?"
and no I don't want the reader to have to know REST is involved prior to reading that code - I want the variable name to hint at that fact if the reader is new to the whole system and doesn't yet know this piece of code is building a packet to be sent by REST

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just because REST doesn't use a "format" it obviously has things by other names which basically do what formats do ... as a physicist by training I accept the need in engineering and software for precise names - but also tend to name a thing by a similar name until someone points out the nuanced issue with my first guess for a label

Copy link
Contributor Author

Choose a reason for hiding this comment

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

( see Roses by other names - W. Shakespeare etc )

Copy link
Contributor

Choose a reason for hiding this comment

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

But naming it after what it does also explains what it does. restful_format explains what it's used for but you need to read the code to figure out what it does.

def given_position_demands() -> AttenuatorMotorPositionDemands:
position_demand = MagicMock()
restful_payload = {"x": 54.3, "y": 72.1, "w": 4}
position_demand.restful_format = MagicMock(return_value=restful_payload)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why does this need to be a magic mock instead of setting the return value directly?

Suggested change
position_demand.restful_format = MagicMock(return_value=restful_payload)
position_demand.restful_format.return_value = restful_payload

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The same "infrastructure" can be used by many tests and other tests might have their own purposes / needs

Once You open the can of MagicMock it should be turtles all the way down as far as I am concerned.

self.headers = HEADERS
super().__init__(name)

def _get_invoking_hutch(self) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
def _get_invoking_hutch(self) -> str:
@property
def _invoking_hutch(self) -> str:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If I read / understand notes on property annotation / decoration of functions - it's primary purpose seems to be to guard public facing methods with the effect of invisible getter and setter methods ( and a free del while we're at it ).

Here the getter itself is not supposed to be public. Is it still obvious that property annotation covers that scenario?

@model_validator(mode="after")
def no_keys_clash(self) -> Self:
common_key_filter = filter(
lambda k: k in self.continuous_demands, self.indexed_demands
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it does do what it means to do, it's just confusingly formatted. lambda k: k in self.continuous_demands, self.indexed_demands are two arguments to the filter function, not a single lambda trying to look up a key in two dicts.

Either way it would be simpler to use & to get the common keys so the whole function becomes

if common := self.continuous_demands.keys() & self.indexed_demands:
    raise ValueError(f"duplicate key(s) found: {common}")
return self

# Value here vould be request params dictionary.
request_params = json.dumps(value)

async with ClientSession(base_url=self.url, raise_for_status=True) as session:
Copy link
Contributor

Choose a reason for hiding this comment

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

Unrelated I think. As far as I'm aware requests doesn't support async calls so something else was needed. There is also httpx that supports both that we looked at switching to at one point (DiamondLightSource/blueapi#1069)

)
position_demands: AttenuatorMotorPositionDemands = given_position_demands()

logger.error.assert_not_called()
Copy link
Contributor

Choose a reason for hiding this comment

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

The await should raise an exception instead of succeeding.


while not plan_complete:
async with session.get(f"/tasks/{task_id}") as res:
plan_result = await res.json()
Copy link
Contributor

Choose a reason for hiding this comment

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

This logic here needs test cases, for what happens if the plan execution fails on the remote server.
We ought to check response ok so that we can gracefully handle the case where we get an HTTP error status but no json payload.



class AttenuatorMotorPositionDemands(BaseModel):
continuous_demands: dict[PermittedKeyStr, float] = {}
Copy link
Contributor

Choose a reason for hiding this comment

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

Interestingly, PyCharm gives me angry red underlining here although pyright doesn't warn about it.
Mutable default '{}' is not allowed. Use 'default_factory'

I'm inclined to agree.

Copy link
Contributor

Choose a reason for hiding this comment

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

Pydantic does some magic to handle mutable default values: https://docs.pydantic.dev/latest/concepts/fields/#mutable-default-values

continuous_demands=wedge_position_demands,
indexed_demands=wheel_position_demands,
)
assert position_demand is not None
Copy link
Contributor

Choose a reason for hiding this comment

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

What are we really asserting here? This will always either raise an exception, in which case the assert is never reached, or be true.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think we could parametrize all the happy path tests together, they don't test anything particularly interesting individually.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Tests don't just test - that is a side function
the primary purpose of tests is documenting actual behaviour -
parameterising tends to obfuscate that function - it can be used when You're just hammering the same nail over and over ( but that strikes me as a waste of testing time in general )
Different scenarios should be indicated-as-having-been-thought-about-distinctly not by being folded and knotted up into the laundry basket of a scruffy parameterised bundle
but should instead be carefully laid out for the reader one well named test at a time

if You want to know what code does, reading the test function names should tell You everything
one bullet point at a time
it also makes clear what the developers haven't thought about

testing is for the CIs benefit and is a very important but far inferior aspect IMHO

* Add attenuator motor squad class to represent a set of mutually
   cooperating motors which are not co-mounted ( therefore do not
   form a physical stage ); but rather act in concert

* Add initimately related position demand class used as driving
   argument to command motor squad to alter the posture of a beamline's
   transmission system

* Add tests for both new classes:
   AttenuatorMotorPositionDemands
   AttenuatorMotorSquad

* Adds access controlled subdirectory in the i19 device tests
   (to match the src devices file system layout)

* Moves established test class for access controlled I19 shutters
   into the access controlled subdirectory

dodal#1384
@CoePaul CoePaul self-assigned this Nov 20, 2025
@rtuck99
Copy link
Contributor

rtuck99 commented Nov 20, 2025

All good apart from the comment regarding the unit test that passes accidentally due to the PUT being checked and not the POST.

I think, given that as I understand from my discussions with @noemifrisina it we are deliberately not checking the POST, that we should update our tests so that we are explictly testing the desired behaviour (even if it goes contrary to normal expectations) with suitable commentary as to why we want this.

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.

4 participants