unxt 0.1.dev463__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of unxt might be problematic. Click here for more details.

Files changed (52) hide show
  1. unxt/__init__.py +79 -0
  2. unxt/_interop/__init__.py +18 -0
  3. unxt/_interop/optional_deps.py +13 -0
  4. unxt/_interop/unxt_interop_astropy/__init__.py +5 -0
  5. unxt/_interop/unxt_interop_astropy/custom_types.py +12 -0
  6. unxt/_interop/unxt_interop_astropy/dimensions.py +53 -0
  7. unxt/_interop/unxt_interop_astropy/quantity.py +308 -0
  8. unxt/_interop/unxt_interop_astropy/units.py +111 -0
  9. unxt/_interop/unxt_interop_gala/__init__.py +5 -0
  10. unxt/_interop/unxt_interop_gala/unitsystems.py +108 -0
  11. unxt/_interop/unxt_interop_mpl/__init__.py +81 -0
  12. unxt/_src/__init__.py +6 -0
  13. unxt/_src/dimensions.py +400 -0
  14. unxt/_src/experimental.py +234 -0
  15. unxt/_src/quantity/__init__.py +24 -0
  16. unxt/_src/quantity/angle.py +58 -0
  17. unxt/_src/quantity/base.py +1160 -0
  18. unxt/_src/quantity/base_angle.py +89 -0
  19. unxt/_src/quantity/base_parametric.py +290 -0
  20. unxt/_src/quantity/flag.py +137 -0
  21. unxt/_src/quantity/mixins.py +238 -0
  22. unxt/_src/quantity/quantity.py +123 -0
  23. unxt/_src/quantity/register_api.py +383 -0
  24. unxt/_src/quantity/register_conversions.py +95 -0
  25. unxt/_src/quantity/register_dispatches.py +246 -0
  26. unxt/_src/quantity/register_primitives.py +5075 -0
  27. unxt/_src/quantity/static_quantity.py +90 -0
  28. unxt/_src/quantity/unchecked.py +45 -0
  29. unxt/_src/quantity/value.py +289 -0
  30. unxt/_src/units.py +103 -0
  31. unxt/_src/unitsystems/__init__.py +40 -0
  32. unxt/_src/unitsystems/base.py +258 -0
  33. unxt/_src/unitsystems/builtin.py +190 -0
  34. unxt/_src/unitsystems/builtin_dimensions.py +21 -0
  35. unxt/_src/unitsystems/compare.py +13 -0
  36. unxt/_src/unitsystems/core.py +307 -0
  37. unxt/_src/unitsystems/flags.py +80 -0
  38. unxt/_src/unitsystems/realizations.py +73 -0
  39. unxt/_src/unitsystems/utils.py +95 -0
  40. unxt/_src/utils.py +112 -0
  41. unxt/_version.py +34 -0
  42. unxt/_version.pyi +2 -0
  43. unxt/dims.py +19 -0
  44. unxt/py.typed +0 -0
  45. unxt/quantity.py +172 -0
  46. unxt/setup_package.py +60 -0
  47. unxt/units.py +19 -0
  48. unxt/unitsystems.py +67 -0
  49. unxt-0.1.dev463.dist-info/METADATA +533 -0
  50. unxt-0.1.dev463.dist-info/RECORD +52 -0
  51. unxt-0.1.dev463.dist-info/WHEEL +4 -0
  52. unxt-0.1.dev463.dist-info/licenses/LICENSE +29 -0
@@ -0,0 +1,108 @@
1
+ """Unitsystem compatibility."""
2
+ # TODO: move to a compatibility module
3
+
4
+ __all__: tuple[str, ...] = ()
5
+
6
+ from typing import Any
7
+
8
+ from astropy.units import UnitBase as AstropyUnit
9
+ from gala.units import ( # pylint: disable=import-error
10
+ DimensionlessUnitSystem as GalaDimensionlessUnitSystem,
11
+ UnitSystem as GalaUnitSystem,
12
+ )
13
+ from plum import conversion_method, convert, dispatch
14
+
15
+ from unxt.unitsystems import AbstractUnitSystem, DimensionlessUnitSystem, dimensionless
16
+
17
+
18
+ @dispatch
19
+ def unitsystem(value: GalaUnitSystem, /) -> AbstractUnitSystem:
20
+ """Return a `gala.units.UnitSystem` as a `unxt.AbstractUnitSystem`.
21
+
22
+ Examples
23
+ --------
24
+ >>> import unxt as u
25
+ >>> import gala.units as gu
26
+ >>> import astropy.units as apyu
27
+
28
+ >>> usys = gu.UnitSystem(apyu.km, apyu.s, apyu.Msun, apyu.radian)
29
+
30
+ >>> u.unitsystem(usys)
31
+ unitsystem(km, s, solMass, rad)
32
+
33
+ """
34
+ # Create a new unit system instance, and possibly class.
35
+ return unitsystem(*value._core_units) # noqa: SLF001
36
+
37
+
38
+ @dispatch
39
+ def unitsystem(_: GalaDimensionlessUnitSystem, /) -> DimensionlessUnitSystem:
40
+ """Return a `gala.units.DimensionlessUnitSystem` as a `unxt.DimensionlessUnitSystem`.
41
+
42
+ Examples
43
+ --------
44
+ >>> import unxt as u
45
+ >>> from gala.units import DimensionlessUnitSystem
46
+
47
+ >>> usys = DimensionlessUnitSystem()
48
+
49
+ >>> u.unitsystem(usys)
50
+ DimensionlessUnitSystem()
51
+
52
+ """ # noqa: E501
53
+ return dimensionless
54
+
55
+
56
+ # =============================================================================
57
+ # Convert
58
+
59
+
60
+ @conversion_method(type_from=GalaUnitSystem, type_to=AbstractUnitSystem) # type: ignore[arg-type]
61
+ def convert_gala_unitsystem_to_unxt_unitsystem(
62
+ usys: GalaUnitSystem, /
63
+ ) -> AbstractUnitSystem:
64
+ """Convert a `gala.units.UnitSystem` to a `unxt.AbstractUnitSystem`.
65
+
66
+ Examples
67
+ --------
68
+ >>> import unxt as u
69
+ >>> import gala.units as gu
70
+ >>> import astropy.units as apyu
71
+
72
+ >>> usys = gu.UnitSystem(apyu.km, apyu.s, apyu.Msun, apyu.radian)
73
+ >>> usys
74
+ <UnitSystem (km, s, solMass, rad)>
75
+
76
+ >>> convert(usys, u.AbstractUnitSystem)
77
+ unitsystem(km, s, solMass, rad)
78
+
79
+ """
80
+ return unitsystem(usys)
81
+
82
+
83
+ @conversion_method(type_from=AbstractUnitSystem, type_to=GalaUnitSystem) # type: ignore[arg-type]
84
+ def convert_unxt_unitsystem_to_gala_unitsystem(
85
+ usys: AbstractUnitSystem, /
86
+ ) -> GalaUnitSystem:
87
+ """Convert a `unxt.AbstractUnitSystem` to a `gala.units.UnitSystem`.
88
+
89
+ Examples
90
+ --------
91
+ >>> import unxt as u
92
+ >>> import gala.units as gu
93
+ >>> import astropy.units as apyu
94
+ >>> from plum import convert
95
+
96
+ >>> usys = u.unitsystem(apyu.km, apyu.s, apyu.Msun, apyu.radian)
97
+ >>> usys
98
+ unitsystem(km, s, solMass, rad)
99
+
100
+ >>> convert(usys, gu.UnitSystem)
101
+ <UnitSystem (km, s, solMass, rad)>
102
+
103
+ """
104
+
105
+ def convert_unit_to_apyu(u: Any) -> AstropyUnit:
106
+ return convert(u, AstropyUnit)
107
+
108
+ return GalaUnitSystem(*map(convert_unit_to_apyu, usys.base_units))
@@ -0,0 +1,81 @@
1
+ """Interoperability with `matplotlib`."""
2
+ # pylint: disable=import-error
3
+
4
+ # This module was adapted from both the `astropy` and `pint` package's
5
+ # implementations.
6
+
7
+ __all__ = ("UnxtConverter", "setup_matplotlib_support_for_unxt")
8
+
9
+
10
+ from collections.abc import Iterable, Sized
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ import matplotlib.units
15
+ from jaxtyping import Array
16
+ from matplotlib.axes import Axes
17
+
18
+ from zeroth import zeroth
19
+
20
+ from unxt.quantity import AbstractQuantity, BareQuantity, ustrip
21
+
22
+
23
+ @dataclass
24
+ class UnxtConverter(matplotlib.units.ConversionInterface): # type: ignore[misc]
25
+ """Support `unxt` in `matplotlib`'s unit conversion framework.
26
+
27
+ This class is a subclass of `matplotlib.units.ConversionInterface`
28
+ and is used to convert `unxt.Quantity` instances for use with
29
+ `matplotlib`.
30
+
31
+ """
32
+
33
+ axisinfo_kw: dict[str, Any] = field(
34
+ default_factory=lambda: {"format": "latex_inline"}
35
+ )
36
+ """Keyword arguments to use when making the :meth:`matplotlib.units.AxisInfo`."""
37
+
38
+ def convert(self, obj: Any, unit: Any, axis: Axes) -> Array | list[Array]:
39
+ """Convert *obj* using *unit* for the specified *axis*."""
40
+ # Hot-path Quantity
41
+ if isinstance(obj, AbstractQuantity):
42
+ return ustrip(unit, obj)
43
+ # Need to recurse (singly) into iterables
44
+ if isinstance(obj, Iterable):
45
+ return [self._convert_value(v, unit, axis) for v in obj]
46
+
47
+ return self._convert_value(obj, unit, axis)
48
+
49
+ @staticmethod
50
+ def _convert_value(obj: Any, unit: Any, axis: Axes) -> Array:
51
+ """Handle converting using attached unit or falling back to axis units."""
52
+ if isinstance(obj, AbstractQuantity):
53
+ return ustrip(unit, obj)
54
+
55
+ return BareQuantity.from_(obj, axis.get_units()).ustrip(unit)
56
+
57
+ def axisinfo(self, unit: Any, _: Axes) -> matplotlib.units.AxisInfo:
58
+ """Return axis information for this particular unit."""
59
+ fmt = self.axisinfo_kw.get("format", "latex_inline")
60
+ return matplotlib.units.AxisInfo(label=unit.to_string(fmt))
61
+
62
+ @staticmethod
63
+ def default_units(x: Any, _: Axes) -> Any:
64
+ """Get the default unit to use for the given combination of unit and axis."""
65
+ if hasattr(x, "unit"):
66
+ return x.unit
67
+ if isinstance(x, Iterable) and isinstance(x, Sized):
68
+ x = zeroth(x)
69
+ return getattr(x, "units", None)
70
+
71
+
72
+ def setup_matplotlib_support_for_unxt(*, enable: bool = True) -> None:
73
+ """Set up matplotlib's unit support for `unxt`.
74
+
75
+ :param enable: Whether support should be enabled or disabled.
76
+
77
+ """
78
+ if enable:
79
+ matplotlib.units.registry[AbstractQuantity] = UnxtConverter()
80
+ else:
81
+ matplotlib.units.registry.pop(AbstractQuantity, None)
unxt/_src/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """unxt: Quantities in JAX.
2
+
3
+ Copyright (c) 2023 Galactic Dynamics. All rights reserved.
4
+ """
5
+
6
+ __all__: tuple[str, ...] = ()
@@ -0,0 +1,400 @@
1
+ """Dimensions module.
2
+
3
+ This is the private implementation of the dimensions module.
4
+
5
+ """
6
+
7
+ __all__ = ("AbstractDimension", "dimension", "dimension_of")
8
+
9
+ import ast
10
+ import importlib.metadata
11
+ import re
12
+ from typing import Any, NoReturn, TypeAlias
13
+
14
+ import astropy.units as apyu
15
+ from packaging.version import Version, parse as parse_version
16
+ from plum import dispatch
17
+
18
+ import unxt_api as uapi
19
+
20
+ AbstractDimension: TypeAlias = apyu.PhysicalType
21
+
22
+ # Regex pattern to detect PEMD operators (Parentheses, Exponentiation,
23
+ # Multiplication, Division). We match: ( ) * / ** but NOT space, +, or -
24
+ _PEMD_PATTERN = re.compile(r"[()*/]|\*\*")
25
+
26
+ # Regex pattern to match parenthesized dimension names that may contain spaces
27
+ _PAREN_DIM_PATTERN = re.compile(r"\(([^()]+)\)")
28
+
29
+
30
+ # ===================================================================
31
+ # Construct the dimensions
32
+
33
+
34
+ def _preprocess_dimension_string(expr: str, /) -> tuple[str, dict[str, str]]:
35
+ """Preprocess dimension string to handle multi-word dimension names.
36
+
37
+ Converts (dimension name) to _dimN for parsing, then stores the mapping.
38
+
39
+ Parameters
40
+ ----------
41
+ expr : str
42
+ The expression string that may contain parenthesized dimension names.
43
+
44
+ Returns
45
+ -------
46
+ str
47
+ The preprocessed expression with valid Python identifiers.
48
+ dict[str, str]
49
+ Mapping from temporary identifiers to original dimension names.
50
+
51
+ """
52
+ dim_mapping: dict[str, str] = {}
53
+ counter = 0
54
+
55
+ def replace_paren_dim(match: re.Match[str], /) -> str:
56
+ nonlocal counter
57
+ # Strip whitespace from the captured dimension name to handle cases like
58
+ # "( amount of substance )" where users might include extra spaces
59
+ dim_name = match.group(1).strip()
60
+ temp_id = f"_dim{counter}"
61
+ dim_mapping[temp_id] = dim_name
62
+ counter += 1
63
+ return temp_id
64
+
65
+ preprocessed = _PAREN_DIM_PATTERN.sub(replace_paren_dim, expr)
66
+ return preprocessed, dim_mapping
67
+
68
+
69
+ def _eval_dimension_node( # noqa: C901
70
+ node: ast.AST,
71
+ /,
72
+ *,
73
+ dim_mapping: dict[str, str] | None = None,
74
+ ) -> AbstractDimension:
75
+ """Recursively evaluate AST nodes into dimensions or numeric values.
76
+
77
+ Parameters
78
+ ----------
79
+ node : ast.AST
80
+ AST node to evaluate.
81
+ dim_mapping : dict[str, str] | None
82
+ Mapping from temporary identifiers to original dimension names.
83
+
84
+ Returns
85
+ -------
86
+ AbstractDimension
87
+ Evaluated dimension.
88
+
89
+ """
90
+ if dim_mapping is None:
91
+ dim_mapping = {}
92
+
93
+ if isinstance(node, ast.Expression):
94
+ return _eval_dimension_node(node.body, dim_mapping=dim_mapping)
95
+
96
+ if isinstance(node, ast.BinOp):
97
+ left = _eval_dimension_node(node.left, dim_mapping=dim_mapping)
98
+
99
+ if isinstance(node.op, ast.Pow):
100
+ # For powers, evaluate the exponent
101
+ # It can be a Constant or a UnaryOp (for negative exponents like **-1)
102
+ if isinstance(node.right, ast.Constant):
103
+ right = node.right.value
104
+ elif isinstance(node.right, ast.UnaryOp) and isinstance(
105
+ node.right.op, ast.USub
106
+ ):
107
+ # Handle negative exponents like **-1
108
+ if isinstance(node.right.operand, ast.Constant):
109
+ right = -node.right.operand.value
110
+ else:
111
+ msg = "Power exponent must be a number"
112
+ raise TypeError(msg)
113
+ else:
114
+ msg = "Power exponent must be a number"
115
+ raise TypeError(msg)
116
+
117
+ if not isinstance(right, int | float):
118
+ msg = f"Power exponent must be a number, got: {type(right).__name__}"
119
+ raise TypeError(msg)
120
+ return left**right
121
+
122
+ # For other operators, evaluate right side normally
123
+ right = _eval_dimension_node(node.right, dim_mapping=dim_mapping)
124
+
125
+ if isinstance(node.op, ast.Mult):
126
+ return left * right
127
+ if isinstance(node.op, ast.Div):
128
+ return left / right
129
+
130
+ msg = f"Unsupported operator: {node.op.__class__.__name__}"
131
+ raise ValueError(msg)
132
+
133
+ if isinstance(node, ast.UnaryOp):
134
+ operand = _eval_dimension_node(node.operand, dim_mapping=dim_mapping)
135
+ if isinstance(node.op, ast.USub):
136
+ return operand**-1
137
+ msg = f"Unsupported unary operator: {node.op.__class__.__name__}"
138
+ raise ValueError(msg)
139
+
140
+ if isinstance(node, ast.Name):
141
+ # Check if this is a temporary identifier that maps to a dimension name
142
+ dim_name = dim_mapping.get(node.id, node.id)
143
+ # This is a dimension name - recursively call dimension()
144
+ return uapi.dimension(dim_name)
145
+
146
+ if isinstance(node, ast.Constant):
147
+ # Handle numeric constants (for exponents)
148
+ return node.value
149
+
150
+ msg = f"Unsupported AST node: {node.__class__.__name__}"
151
+ raise ValueError(msg)
152
+
153
+
154
+ def _parse_dimension_string(expr: str, /) -> AbstractDimension:
155
+ """Parse a dimension string with mathematical operations.
156
+
157
+ Supports *, /, and ** operators following PEMDAS. Dimension names can
158
+ be parenthesized and may contain spaces, e.g., "(amount of substance)".
159
+
160
+ Parameters
161
+ ----------
162
+ expr : str
163
+ Mathematical expression like "length / time**2" or
164
+ "(amount of substance) / (time)"
165
+
166
+ Returns
167
+ -------
168
+ AbstractDimension
169
+ The resulting physical type from evaluating the expression.
170
+
171
+ Examples
172
+ --------
173
+ >>> _parse_dimension_string("length / time**2")
174
+ PhysicalType(...)
175
+
176
+ """
177
+ # Normalize whitespace
178
+ expr = expr.strip()
179
+
180
+ # Preprocess to handle multi-word dimension names in parentheses
181
+ preprocessed, dim_mapping = _preprocess_dimension_string(expr)
182
+
183
+ # Parse the expression into an AST
184
+ try:
185
+ tree = ast.parse(preprocessed, mode="eval")
186
+ except SyntaxError as e:
187
+ msg = f"Invalid dimension expression: {expr}"
188
+ raise ValueError(msg) from e
189
+
190
+ return _eval_dimension_node(tree, dim_mapping=dim_mapping)
191
+
192
+
193
+ @dispatch
194
+ def dimension(obj: AbstractDimension, /) -> AbstractDimension:
195
+ """Construct dimension from a dimension object.
196
+
197
+ Examples
198
+ --------
199
+ >>> import unxt as u
200
+ >>> import astropy.units as apyu
201
+
202
+ >>> length = apyu.get_physical_type("length")
203
+ >>> length
204
+ PhysicalType('length')
205
+
206
+ >>> u.dimension(length) is length
207
+ True
208
+
209
+ """
210
+ return obj
211
+
212
+
213
+ @dispatch
214
+ def dimension(obj: str, /) -> AbstractDimension:
215
+ """Construct dimension from a string.
216
+
217
+ The string can be:
218
+ 1. A simple dimension name (e.g., "length", "time", "mass")
219
+ 2. A multi-word dimension name (e.g., "amount of substance", "absement")
220
+ 3. A mathematical expression using *, /, and ** operators
221
+
222
+ Mathematical Expressions:
223
+
224
+ Expressions are evaluated using operator precedence (PEMDAS):
225
+ - ** (exponentiation, highest precedence)
226
+ - * and / (multiplication and division, equal precedence, left-to-right)
227
+
228
+ Parentheses are supported for grouping and for dimension names with spaces.
229
+
230
+ Operators Supported:
231
+ - `*` : Multiplication (e.g., "length * time")
232
+ - `/` : Division (e.g., "length / time")
233
+ - `**` : Exponentiation (e.g., "length**2")
234
+
235
+ Unsupported Operators:
236
+ - `+` and `-` are NOT supported as operators since dimensions are invariant
237
+ under addition and subtraction. They are treated as part of dimension names.
238
+
239
+ Rules for Dimension Names in Expressions:
240
+ - Single-word names don't need parentheses: "length * time"
241
+ - Multi-word names MUST be parenthesized: "(amount of substance) * time"
242
+ - Parenthesized single-word names are allowed: "(length) / (time)"
243
+ - Whitespace is flexible: "length / time", "length/time", "length / time**2"
244
+
245
+ Examples
246
+ --------
247
+ >>> from unxt.dims import dimension
248
+
249
+ **Simple dimension names:**
250
+
251
+ >>> dimension("length")
252
+ PhysicalType('length')
253
+
254
+ >>> dimension("time")
255
+ PhysicalType('time')
256
+
257
+ >>> dimension("mass")
258
+ PhysicalType('mass')
259
+
260
+ **Multi-word dimension names:**
261
+
262
+ >>> dimension("amount of substance")
263
+ PhysicalType('amount of substance')
264
+
265
+ **Mathematical expressions with single-word names:**
266
+
267
+ >>> dimension("length / time")
268
+ PhysicalType({'speed', 'velocity'})
269
+
270
+ >>> dimension("length**2")
271
+ PhysicalType('area')
272
+
273
+ >>> dimension("length * mass / time**2")
274
+ PhysicalType('force')
275
+
276
+ **Parenthesized expressions:**
277
+
278
+ >>> dimension("(length) / (time)")
279
+ PhysicalType({'speed', 'velocity'})
280
+
281
+ **Expressions with multi-word dimension names:**
282
+
283
+ >>> dimension("(amount of substance) / (time)")
284
+ PhysicalType('catalytic activity')
285
+
286
+ **Mixed expressions (multi-word with parentheses, single-word without):**
287
+
288
+ >>> dimension("length * (amount of substance)")
289
+ PhysicalType('unknown')
290
+
291
+ >>> dimension("(absement) / (time)")
292
+ PhysicalType('length')
293
+
294
+ See Also
295
+ --------
296
+ dimension_of : Get the dimension of an object
297
+ unxt.units : Unit specifications can also use dimension expressions
298
+
299
+ """
300
+ # Check if the string contains PEMD operators using regex
301
+ # We only consider (), *, /, ** as operators - not space, +, or -
302
+ if _PEMD_PATTERN.search(obj):
303
+ return _parse_dimension_string(obj)
304
+
305
+ # Simple dimension name - use astropy directly
306
+ return apyu.get_physical_type(obj)
307
+
308
+
309
+ # ===================================================================
310
+ # Get the dimension
311
+
312
+
313
+ @dispatch
314
+ def dimension_of(obj: Any, /) -> None:
315
+ """Most objects have no dimension.
316
+
317
+ Examples
318
+ --------
319
+ >>> from unxt.dims import dimension_of
320
+
321
+ >>> print(dimension_of(1))
322
+ None
323
+
324
+ >>> print(dimension_of("length"))
325
+ None
326
+
327
+ """
328
+ return None # noqa: RET501
329
+
330
+
331
+ @dispatch
332
+ def dimension_of(obj: AbstractDimension, /) -> AbstractDimension:
333
+ """Return the dimension of the given units.
334
+
335
+ Examples
336
+ --------
337
+ >>> from unxt.dims import dimension, dimension_of
338
+
339
+ >>> dimension_of(dimension("length"))
340
+ PhysicalType('length')
341
+
342
+ """
343
+ return obj
344
+
345
+
346
+ @dispatch
347
+ def dimension_of(obj: type, /) -> NoReturn:
348
+ """Get the dimension of a type.
349
+
350
+ Examples
351
+ --------
352
+ >>> import unxt as u
353
+
354
+ >>> try:
355
+ ... u.dimension_of(u.quantity.BareQuantity)
356
+ ... except ValueError as e:
357
+ ... print(e)
358
+ Cannot get the dimension of <class 'unxt._src.quantity.unchecked.BareQuantity'>.
359
+
360
+ """
361
+ msg = f"Cannot get the dimension of {obj}."
362
+ raise ValueError(msg)
363
+
364
+
365
+ # ===================================================================
366
+ # COMPAT
367
+
368
+ ASTROPY_LT_71 = parse_version(importlib.metadata.version("astropy")) < Version("7.1")
369
+
370
+
371
+ @dispatch
372
+ def name_of(dim: AbstractDimension, /) -> str:
373
+ """Name of a dimension.
374
+
375
+ Examples
376
+ --------
377
+ >>> import unxt as u
378
+
379
+ >>> name_of(u.dimension("length"))
380
+ 'length'
381
+
382
+ >>> name_of(u.dimension("speed"))
383
+ 'speed'
384
+
385
+ >>> name_of(u.dimension("mass density"))
386
+ 'mass density'
387
+
388
+ """
389
+ if dim == "unknown":
390
+ ptid = dim._unit._physical_type_id # noqa: SLF001
391
+ name = " ".join(
392
+ f"{unit}{power}" if power != 1 else unit for unit, power in ptid
393
+ )
394
+
395
+ elif ASTROPY_LT_71:
396
+ name = dim._name_string_as_ordered_set().split("'")[1] # noqa: SLF001
397
+ else:
398
+ name = dim._physical_type[0] # noqa: SLF001
399
+
400
+ return name