diff --git a/.github/workflows/code.yml b/.github/workflows/code.yml index 3e0c091..023a7c1 100644 --- a/.github/workflows/code.yml +++ b/.github/workflows/code.yml @@ -96,7 +96,7 @@ jobs: if: ${{ !matrix.deploy }} run: | python -m pip install --upgrade pip - pip install .[dev] + pip install .[dev] -r requirements.txt - name: Install dependencies (deploy) if: ${{ matrix.deploy }} diff --git a/.vscode/launch.json b/.vscode/launch.json index 2e54e7f..4677d53 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ "--reload" ], "jinja": true, - "justMyCode": true + "justMyCode": false }, { "name": "Debug Unit Test", diff --git a/requirements.txt b/requirements.txt index a52251f..a796e83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ certifi==2022.9.24 cfgv==3.3.1 click==8.1.3 coverage==6.5.0 -git+https://github.com/DiamondLightSource/diffcalc-core.git#egg=diffcalc-core +git+https://github.com/DiamondLightSource/diffcalc-core@addExtraFunctions#egg=diffcalc-core distlib==0.3.6 docutils==0.19 fastapi==0.86.0 diff --git a/src/diffcalc_api/errors/ub.py b/src/diffcalc_api/errors/ub.py index a359833..90ccc7a 100644 --- a/src/diffcalc_api/errors/ub.py +++ b/src/diffcalc_api/errors/ub.py @@ -19,6 +19,8 @@ class ErrorCodes(ErrorCodesBase): NO_TAG_OR_IDX_PROVIDED = 400 BOTH_TAG_OR_IDX_PROVIDED = 400 NO_UB_MATRIX_ERROR = 400 + NO_CRYSTAL_ERROR = 400 + INVALID_INDEX_ERROR = 400 responses = {code: ALL_RESPONSES[code] for code in np.unique(ErrorCodes.all_codes())} @@ -98,3 +100,31 @@ def __init__(self, message: Optional[str] = None): else message ) self.status_code = ErrorCodes.NO_UB_MATRIX_ERROR + + +class NoCrystalError(DiffcalcAPIException): + """When there is no crystal lattice set, some commands in diffcalc-core fail.""" + + def __init__(self, message: Optional[str] = None): + """Set detail and status code.""" + self.detail = ( + ( + "It seems like there is no crystal lattice set for this record. Please" + + " try again after setting the lattice." + ) + if message is None + else message + ) + self.status_code = ErrorCodes.NO_UB_MATRIX_ERROR + + +class InvalidIndexError(DiffcalcAPIException): + """Error gets thrown if an invalid miller index name is given.""" + + def __init__(self, index: str): + """Set detail and status code.""" + self.detail = ( + "Index supplied must be one of {h, k, l}. " + f"Found {index} instead." + ) + + self.status_code = ErrorCodes.INVALID_INDEX_ERROR diff --git a/src/diffcalc_api/models/response.py b/src/diffcalc_api/models/response.py index d3b4ae7..12c26ea 100644 --- a/src/diffcalc_api/models/response.py +++ b/src/diffcalc_api/models/response.py @@ -1,9 +1,9 @@ """Pydantic models relating to all endpoint responses.""" -from typing import Dict, List +from typing import Dict, List, Union from pydantic import BaseModel -from diffcalc_api.models.ub import HklModel, MiscutModel +from diffcalc_api.models.ub import HklModel, MiscutModel, SphericalCoordinates, XyzModel class InfoResponse(BaseModel): @@ -46,14 +46,14 @@ class DiffractorAnglesResponse(BaseModel): payload: List[Dict[str, float]] -class MillerIndicesResponse(BaseModel): - """Miller Indices Response. +class CoordinateResponse(BaseModel): + """Coordinate response model. - Used for any endpoint with an attached service that returns a set of miller - indices. + Returns coordinates, in three dimensions, for a given coordinate system. + Supported systems include spherical coordinates, reciprocal and real space. """ - payload: HklModel + payload: Union[SphericalCoordinates, HklModel, XyzModel] class MiscutResponse(BaseModel): diff --git a/src/diffcalc_api/models/ub.py b/src/diffcalc_api/models/ub.py index 524c917..6109611 100644 --- a/src/diffcalc_api/models/ub.py +++ b/src/diffcalc_api/models/ub.py @@ -95,3 +95,14 @@ class MiscutModel(BaseModel): angle: float rotation_axis: XyzModel + + +class SphericalCoordinates(BaseModel): + """Data storing a point in the spherical coordinate frame. + + Requires an azimuthal and polar angle, as well as a magnitude. + """ + + magnitude: float + azimuth_angle: float + polar_angle: float diff --git a/src/diffcalc_api/routes/hkl.py b/src/diffcalc_api/routes/hkl.py index c44fb6f..f5a9207 100644 --- a/src/diffcalc_api/routes/hkl.py +++ b/src/diffcalc_api/routes/hkl.py @@ -7,8 +7,8 @@ from diffcalc_api.errors.hkl import InvalidSolutionBoundsError from diffcalc_api.models.hkl import SolutionConstraints from diffcalc_api.models.response import ( + CoordinateResponse, DiffractorAnglesResponse, - MillerIndicesResponse, ScanResponse, ) from diffcalc_api.models.ub import HklModel, PositionModel @@ -60,7 +60,7 @@ async def lab_position_from_miller_indices( return DiffractorAnglesResponse(payload=positions) -@router.get("/{name}/position/hkl", response_model=MillerIndicesResponse) +@router.get("/{name}/position/hkl", response_model=CoordinateResponse) async def miller_indices_from_lab_position( name: str, pos: PositionModel = Depends(), @@ -78,12 +78,12 @@ async def miller_indices_from_lab_position( collection: collection within which the hkl object resides. Returns: - MillerIndicesResponse containing the miller indices. + CoordinateResponse containing the miller indices. """ hkl = await service.miller_indices_from_lab_position( name, pos, wavelength, store, collection ) - return MillerIndicesResponse(payload=hkl) + return CoordinateResponse(payload=hkl) @router.get("/{name}/scan/hkl", response_model=ScanResponse) diff --git a/src/diffcalc_api/routes/ub.py b/src/diffcalc_api/routes/ub.py index 78c43da..19cb98d 100644 --- a/src/diffcalc_api/routes/ub.py +++ b/src/diffcalc_api/routes/ub.py @@ -12,6 +12,7 @@ from diffcalc_api.examples import ub as examples from diffcalc_api.models.response import ( ArrayResponse, + CoordinateResponse, InfoResponse, MiscutResponse, StringResponse, @@ -25,6 +26,7 @@ MiscutModel, PositionModel, SetLatticeParams, + SphericalCoordinates, XyzModel, select_idx_or_tag_str, ) @@ -534,7 +536,7 @@ async def set_lab_reference_vector( ) -@router.put("/{name}/nhkl") +@router.put("/{name}/nhkl", response_model=InfoResponse) async def set_miller_reference_vector( name: str, target_value: HklModel = Body(..., example={"h": 1, "k": 0, "l": 0}), @@ -559,7 +561,7 @@ async def set_miller_reference_vector( ) -@router.put("/{name}/surface/nphi") +@router.put("/{name}/surface/nphi", response_model=InfoResponse) async def set_lab_surface_normal( name: str, target_value: XyzModel = Body(..., example={"x": 1, "y": 0, "z": 0}), @@ -584,7 +586,7 @@ async def set_lab_surface_normal( ) -@router.put("/{name}/surface/nhkl") +@router.put("/{name}/surface/nhkl", response_model=InfoResponse) async def set_miller_surface_normal( name: str, target_value: HklModel = Body(..., example={"h": 1, "k": 0, "l": 0}), @@ -636,7 +638,7 @@ async def get_lab_reference_vector( return InfoResponse(message="This vector does not exist.") -@router.get("/{name}/nhkl") +@router.get("/{name}/nhkl", response_model=Union[ArrayResponse, InfoResponse]) async def get_miller_reference_vector( name: str, store: HklCalcStore = Depends(get_store), @@ -663,7 +665,7 @@ async def get_miller_reference_vector( return InfoResponse(message="This vector does not exist.") -@router.get("/{name}/surface/nphi") +@router.get("/{name}/surface/nphi", response_model=Union[ArrayResponse, InfoResponse]) async def get_lab_surface_normal( name: str, store: HklCalcStore = Depends(get_store), @@ -690,7 +692,7 @@ async def get_lab_surface_normal( return InfoResponse(message="This vector does not exist.") -@router.get("/{name}/surface/nhkl") +@router.get("/{name}/surface/nhkl", response_model=Union[ArrayResponse, InfoResponse]) async def get_miller_surface_normal( name: str, store: HklCalcStore = Depends(get_store), @@ -715,3 +717,139 @@ async def get_miller_surface_normal( return ArrayResponse(payload=lab_vector) else: return InfoResponse(message="This vector does not exist.") + + +####################################################################################### +# Vector Calculations in HKL Space # +####################################################################################### + + +@router.get("/{name}/vector", response_model=CoordinateResponse) +async def calculate_vector_from_hkl_and_offset( + name: str, + hkl_ref: HklModel = Depends(), + polar_angle: float = Query(..., example=45.0), + azimuth_angle: float = Query(..., example=45.0), + store: HklCalcStore = Depends(get_store), + collection: Optional[str] = Query(default=None, example="B07"), +): + """Calculate a vector in reciprocal space relative to a reference vector. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved. + + Args: + name: the name of the hkl object to access within the store + hkl_ref: the reference vector in hkl space + polar_angle: the polar angle, or the inclination between the zenith and + reference vector. + azimuth_angle: the azimuth angle + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + CoordinateResponse + Containing the calculated reciprocal space vector as h, k, l indices. + + """ + vector = await service.calculate_vector_from_hkl_and_offset( + name, hkl_ref, polar_angle, azimuth_angle, store, collection + ) + + return CoordinateResponse(payload=HklModel(h=vector[0], k=vector[1], l=vector[2])) + + +@router.get("/{name}/offset", response_model=CoordinateResponse) +async def calculate_offset_from_vector_and_hkl( + name: str, + h1: float = Query(..., example=0.0), + k1: float = Query(..., example=1.0), + l1: float = Query(..., example=0.0), + h2: float = Query(..., example=1.0), + k2: float = Query(..., example=0.0), + l2: float = Query(..., example=0.0), + store: HklCalcStore = Depends(get_store), + collection: Optional[str] = Query(default=None, example="B07"), +): + """Calculate angles and magnitude differences between two reciprocal space vectors. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved, + and that a lattice has been set for it. + + Args: + name: the name of the hkl object to access within the store + h1: h index of the reference vector + k1: k index of the reference vector + l1: l index of the reference vector + h2: h index of the vector relative to which the offset should be calculated + k2: k index of the vector relative to which the offset should be calculated + l2: l index of the vector relative to which the offset should be calculated + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + CoordinateResponse + The offset, in spherical coordinates, between the two reciprocal space vectors, + containing the polar angle, azimuth angle and magnitude between them. + + """ + hkl_ref = HklModel(h=h1, k=k1, l=l1) + hkl_offset = HklModel(h=h2, k=k2, l=l2) + + vector = await service.calculate_offset_from_vector_and_hkl( + name, hkl_offset, hkl_ref, store, collection + ) + + return CoordinateResponse( + payload=SphericalCoordinates( + polar_angle=vector[0], azimuth_angle=vector[1], magnitude=vector[2] + ) + ) + + +####################################################################################### +# HKL Solver For Fixed Scattering Vector # +####################################################################################### + + +@router.get("/{name}/solve/hkl/fixed/q", response_model=ArrayResponse) +async def hkl_solver_for_fixed_q( + name: str, + hkl: HklModel = Depends(), + index_name: str = Query(..., example="h"), + index_value: float = Query(..., example=0.0), + a: float = Query(..., example=0.0), + b: float = Query(..., example=1.0), + c: float = Query(..., example=0.0), + d: float = Query(..., example=0.25), + store: HklCalcStore = Depends(get_store), + collection: Optional[str] = Query(default=None, example="B07"), +): + """Find valid hkl indices for a fixed scattering vector. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved. + Coefficients are used to constrain solutions as: + a*h + b*k + c*l = d + + Args: + name: the name of the hkl object to access within the store. + hkl: Reciprocal space vector from which a scattering vector will be calculated. + index_name: Which miller index to set, + index_value: value of this miller index. + a: constraint on the hkl value. + b: constraint on the hkl value. + c: constraint on the hkl value. + d: constraint on the hkl value. + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + ArrayResponse + A list of lists, with each sublist being a solution. + + """ + hkl_list = await service.hkl_solver_for_fixed_q( + name, hkl, index_name, index_value, a, b, c, d, store, collection + ) + hkl_list_as_list_of_lists = [list(i) for i in hkl_list] + + return ArrayResponse(payload=hkl_list_as_list_of_lists) diff --git a/src/diffcalc_api/services/ub.py b/src/diffcalc_api/services/ub.py index 43a13be..7f898cf 100644 --- a/src/diffcalc_api/services/ub.py +++ b/src/diffcalc_api/services/ub.py @@ -1,12 +1,17 @@ """Business logic for handling requests from ub endpoints.""" -from typing import List, Optional, Tuple, Union +from typing import List, Literal, Optional, Tuple, Union, cast import numpy as np from diffcalc.hkl.geometry import Position from diffcalc.ub.calc import UBCalculation -from diffcalc_api.errors.ub import NoUbMatrixError, ReferenceRetrievalError +from diffcalc_api.errors.ub import ( + InvalidIndexError, + NoCrystalError, + NoUbMatrixError, + ReferenceRetrievalError, +) from diffcalc_api.models.ub import ( AddOrientationParams, AddReflectionParams, @@ -727,3 +732,151 @@ async def get_miller_surface_normal( surf_nhkl = ubcalc.surf_nhkl return surf_nhkl.tolist() if surf_nhkl is not None else None + + +####################################################################################### +# Vector Calculations in HKL Space # +####################################################################################### + + +async def calculate_vector_from_hkl_and_offset( + name: str, + hkl_ref: HklModel, + polar_angle: float, + azimuth_angle: float, + store: HklCalcStore, + collection, +) -> Tuple[float, float, float]: + """Calculate a vector in reciprocal space relative to a reference vector. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved. + + Args: + name: the name of the hkl object to access within the store + hkl_ref: the reference vector in hkl space + polar_angle: the polar angle, or the inclination between the zenith and + reference vector. + azimuth_angle: the azimuth angle + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + Tuple[float, float, float] + The calculated vector, related by an offset to the reference vector given. + + """ + hklcalc = await store.load(name, collection) + ubcalc: UBCalculation = hklcalc.ubcalc + + if ubcalc.UB is None: + raise NoUbMatrixError() + + offset_hkl = ubcalc.calc_vector_wrt_hkl_and_offset( + (hkl_ref.h, hkl_ref.k, hkl_ref.l), polar_angle, azimuth_angle + ) + + return offset_hkl + + +async def calculate_offset_from_vector_and_hkl( + name: str, + hkl_offset: HklModel, + hkl_ref: HklModel, + store: HklCalcStore, + collection, +) -> Tuple[float, float, float]: + """Calculate angles and magnitude differences between two reciprocal space vectors. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved, + and that a lattice has been set for it. + + Args: + name: the name of the hkl object to access within the store. + hkl_offset: The offset reciprocal space vector. + hkl_ref: The reference reciprocal space vector. + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + Tuple[float, float, float] + The offset, in spherical coordinates, between the two reciprocal space vectors, + containing the polar angle, azimuth angle and magnitude between them. + + """ + hklcalc = await store.load(name, collection) + ubcalc: UBCalculation = hklcalc.ubcalc + + if ubcalc.UB is None: + raise NoUbMatrixError() + if ubcalc.crystal is None: + raise NoCrystalError() + + if hkl_offset == hkl_ref: + offset = (0.0, 0.0, 1.0) + else: + offset = ubcalc.calc_offset_wrt_vector_and_hkl( + (hkl_offset.h, hkl_offset.k, hkl_offset.l), + (hkl_ref.h, hkl_ref.k, hkl_ref.l), + ) + + return offset + + +####################################################################################### +# HKL Solver For Fixed Scattering Vector # +####################################################################################### + + +async def hkl_solver_for_fixed_q( + name: str, + hkl: HklModel, + index_name: str, + index_value: float, + a: float, + b: float, + c: float, + d: float, + store: HklCalcStore, + collection, +) -> List[Tuple[float, float, float]]: + """Find valid hkl indices for a fixed scattering vector. + + Note, this method requires that a UB matrix exists for the Hkl object retrieved. + Coefficients are used to constrain solutions as: + a*h + b*k + c*l = d + + Args: + name: the name of the hkl object to access within the store. + hkl: Reciprocal space vector from which a scattering vector will be calculated. + index_name: Which miller index to set, + index_value: value of this miller index. + a: constraint on the hkl value. + b: constraint on the hkl value. + c: constraint on the hkl value. + d: constraint on the hkl value. + store: accessor to the hkl object. + collection: collection within which the hkl object resides. + + Returns: + List[Tuple[float, float, float]] + A pair of solutions to the intersection of an ellipsoid with a reference plane. + + """ + hklcalc = await store.load(name, collection) + ubcalc: UBCalculation = hklcalc.ubcalc + + if ubcalc.UB is None: + raise NoUbMatrixError() + + q_vector = ubcalc.UB @ np.array([[hkl.h], [hkl.k], [hkl.l]]) + q_value = np.linalg.norm(q_vector) ** 2 + + if not ((index_name == "h") or (index_name == "k") or (index_name == "l")): + raise InvalidIndexError(index_name) + + index_as_literal = cast(Literal["h", "k", "l"], index_name) + + hkl_list = ubcalc.solve_for_hkl_given_fixed_index_and_q( + index_as_literal, index_value, q_value, a, b, c, d + ) + return hkl_list diff --git a/tests/test_ubcalc.py b/tests/test_ubcalc.py index 7b3a6b4..2bef1f6 100644 --- a/tests/test_ubcalc.py +++ b/tests/test_ubcalc.py @@ -10,7 +10,12 @@ from diffcalc.ub.calc import UBCalculation from fastapi.testclient import TestClient -from diffcalc_api.errors.ub import ErrorCodes, NoUbMatrixError +from diffcalc_api.errors.ub import ( + ErrorCodes, + InvalidIndexError, + NoCrystalError, + NoUbMatrixError, +) from diffcalc_api.server import app from diffcalc_api.stores.protocol import get_store from tests.conftest import FakeHklCalcStore @@ -469,3 +474,139 @@ def test_get_and_set_reference_vectors_hkl( np.array(literal_eval(response_get.content.decode())["payload"]).T == body_as_array ) + + +def test_calculate_vector_from_hkl_and_offset(): + ubcalc = UBCalculation() + ubcalc.UB = np.identity(3) + + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response = client.get( + "/ub/test/vector?polar_angle=90.0&azimuth_angle=0.0&collection=B07", + params={"h": 1.0, "k": 0.0, "l": 0.0}, + ) + + assert response.status_code == 200 + response_miller = literal_eval(response.content.decode())["payload"] + + assert response_miller["h"] == pytest.approx(0.0) + assert response_miller["k"] == pytest.approx(1.0) + assert response_miller["l"] == pytest.approx(0.0) + + +def test_calculate_vector_from_hkl_and_offset_fails_for_no_ub_matrix(): + ubcalc = UBCalculation() + + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response = client.get( + "/ub/test/vector?polar_angle=90.0&azimuth_angle=0.0&collection=B07", + params={"h": 1.0, "k": 0.0, "l": 0.0}, + ) + + assert response.status_code == ErrorCodes.NO_UB_MATRIX_ERROR + + content = literal_eval(response.content.decode()) + + assert content["type"] == str(NoUbMatrixError) + assert content["message"].startswith( + "It seems like there is no UB matrix for this record." + ) + + +def test_calculate_offset_from_vector_and_hkl(): + ubcalc = UBCalculation() + ubcalc.UB = np.identity(3) + ubcalc.set_lattice("", "Cubic", 1.0) + + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response = client.get( + "/ub/test/offset?h1=1.0&k1=0.0&l1=0.0&h2=0.0&k2=1.0&l2=0.0&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response.status_code == 200 + response_spherical = literal_eval(response.content.decode())["payload"] + + assert response_spherical["magnitude"] == 1.0 + assert response_spherical["azimuth_angle"] == 0.0 + assert response_spherical["polar_angle"] == 90.0 + + +def test_calculate_offset_from_vector_and_hkl_fails_for_no_ub_matrix_or_crystal(): + ubcalc = UBCalculation() + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response_no_ub = client.get( + "/ub/test/offset?h1=1.0&k1=0.0&l1=0.0&h2=0.0&k2=1.0&l2=0.0&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response_no_ub.status_code == ErrorCodes.NO_UB_MATRIX_ERROR + content_no_ub = literal_eval(response_no_ub.content.decode()) + assert content_no_ub["type"] == str(NoUbMatrixError) + + ubcalc.UB = np.identity(3) + + response_no_crystal = client.get( + "/ub/test/offset?h1=1.0&k1=0.0&l1=0.0&h2=0.0&k2=1.0&l2=0.0&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response_no_crystal.status_code == ErrorCodes.NO_CRYSTAL_ERROR + content_no_crystal = literal_eval(response_no_crystal.content.decode()) + assert content_no_crystal["type"] == str(NoCrystalError) + + +def test_hkl_solver_for_fixed_q(): + ubcalc = UBCalculation() + ubcalc.UB = np.identity(3) + + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response = client.get( + "/ub/test/solve/hkl/fixed/q?" + + "index_name=h&index_value=0.0&a=0.0&b=1.0&c=0.0&d=0.25&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response.status_code == 200 + + hkl_list = literal_eval(response.content.decode())["payload"] + assert hkl_list == [ + [0.0, 0.25, -0.9682458365518543], + [0.0, 0.25, 0.9682458365518543], + ] + + +def test_hkl_solver_for_fixed_q_fails_if_no_ub_or_invalid_index_given(): + ubcalc = UBCalculation() + hkl = HklCalculation(ubcalc, Constraints()) + client = Client(hkl).client + + response = client.get( + "/ub/test/solve/hkl/fixed/q?" + + "index_name=h&index_value=0.0&a=0.0&b=1.0&c=0.0&d=0.25&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response.status_code == ErrorCodes.NO_UB_MATRIX_ERROR + assert literal_eval(response.content.decode())["type"] == str(NoUbMatrixError) + + ubcalc.UB = np.identity(3) + + response = client.get( + "/ub/test/solve/hkl/fixed/q?" + + "index_name=p&index_value=0.0&a=0.0&b=1.0&c=0.0&d=0.25&collection=B07", + params={"h": 0.0, "k": 1.0, "l": 0.0}, + ) + + assert response.status_code == ErrorCodes.INVALID_INDEX_ERROR + assert literal_eval(response.content.decode())["type"] == str(InvalidIndexError)