Repository: sympy/sympy
Pull request: feat(physics): `quantity_simplify` can now optionally simplify units across dimensions
<!-- Your title above should be a short description of what
was changed. Do not include the issue number in the title. -->

#### References to other Issues or PRs
<!-- If this pull request fixes an issue, write "Fixes #NNNN" in that exact
format, e.g. "Fixes #1234" (see
https://tinyurl.com/auto-closing for more information). Also, please
write a comment on that issue linking back to this pull request once it is
open. -->
Fixes #21234

#### Brief description of what is fixed or changed
Added an optional flag, `across_dimensions` to the function `quantity_simplify`, which allows units to be simplified across dimensions, eg. joule/coulomb -> volt.

#### Other comments


#### Release Notes

<!-- Write the release notes for this release below between the BEGIN and END
statements. The basic format is a bulleted list with the name of the subpackage
and the release note for this PR. For example:

* solvers
  * Added a new solver for logarithmic equations.

* functions
  * Fixed a bug with log of integers.

or if no release note(s) should be included use:

NO ENTRY

See https://github.com/sympy/sympy/wiki/Writing-Release-Notes for more
information on how to write release notes. The bot will check your release
notes automatically to see if they are formatted correctly. -->

<!-- BEGIN RELEASE NOTES -->
* physics.units
  * `Quantity`: it is now possible to determine if a Quantity is prefixed (eg. `nanometers`) with the property `is_prefixed`.
  * `Quantity`: it is now possible to determine if a Quantity represents a physics constant (eg. `speed_of_light`) with the property `is_physical_constant` or if is an instance of the new subclass `PhysicalConstant`.
  * `quantity_simplify()` can now simplify units across dimensions using the optional flag `across_dimensions=True`.
<!-- END RELEASE NOTES -->


Problem statement:
Can't get joule/m^3 to simplify into pascal
Sample:
```python
from sympy.physics.units import *
print(joule/meter**3)
print(simplify(joule/meter**3))
```
I expected one of these to give me pascal as the unit, but instead I get `joule/meter**3` in both cases.

If I try `(joule/meter**3).convert_to(pascal)` instead, I get `AttributeError: 'Mul' object has no attribute 'convert_to'`. However, `convert_to(joule/meter**3, pascal)` succeeds.

I also tried `sympy.physics.units.util.quantity_simplify(joule/meter**3)`, but that also gives me `joule/meter**3`

Hints from issue discussion:
I'd like to work on this issue.
@dyc3 I looked over this and tested some parts of it, from what I understood, is that quantity_simplify() only works when the parameters are of the same unit, in the code, there is an example:

>>> quantity_simplify(kilo*foot*inch)
    250*foot**2/3

This works because foot and inch are both units of length, unlike your expression.
Let me know if I have erred somewhere!
I'm looking more into the code and will let you know if I find something :)
For `quantity_simplify`, the docs state:

> Return an equivalent expression in which prefixes are replaced
    with numerical values and **all units of a given dimension are the
    unified** in a canonical manner.

So technically, this is expected behavior. However, I think that `quantity_simplify` should be expanded to cover cases like this, where the dimensions of the unit in the expression can be simplified into a different unit. These types of unit simplifications are not necessarily intuitive to end users, so being able to have sympy cover it would help.

For example:

- `joule/meter**3` -> `pascal`
- `ampere * ohm` -> `volt`
- `farad * ohm` -> `second`
- `joule / second` -> `watt`


It also might be nice to have this extra feature gated behind a flag argument that defaults to true.
Opened a PR: #23218

Commit message: refactor(physics.units): hard code preferred units map into each unit system

I also took the liberty of renaming some imports to be more explicit and clear.

Patch to review:
diff --git a/.mailmap b/.mailmap
index 18f20831db4a..ee94aa49dc36 100644
--- a/.mailmap
+++ b/.mailmap
@@ -271,6 +271,7 @@ Calvin Jay Ross <calvinjayross@gmail.com>
 Cameron King <v-caking@microsoft.com>
 Carl Sandrock <carl.sandrock@up.ac.za>
 Carlos Cordoba <ccordoba12@gmail.com>
+Carson McManus <carson.mcmanus1@gmail.com> Carson McManus <dyc3@users.noreply.github.com>
 Carsten Knoll <CarstenKnoll@gmx.de>
 Case Van Horsen <casevh@gmail.com>
 Cavendish McKay <cmckay@tachycline.com>
diff --git a/sympy/core/tests/test_args.py b/sympy/core/tests/test_args.py
index b20e408567be..171312eba78e 100644
--- a/sympy/core/tests/test_args.py
+++ b/sympy/core/tests/test_args.py
@@ -4251,6 +4251,11 @@ def test_sympy__physics__units__quantities__Quantity():
     assert _test_args(Quantity("dam"))
 
 
+def test_sympy__physics__units__quantities__PhysicalConstant():
+    from sympy.physics.units.quantities import PhysicalConstant
+    assert _test_args(PhysicalConstant("foo"))
+
+
 def test_sympy__physics__units__prefixes__Prefix():
     from sympy.physics.units.prefixes import Prefix
     assert _test_args(Prefix('kilo', 'k', 3))
diff --git a/sympy/physics/units/definitions/unit_definitions.py b/sympy/physics/units/definitions/unit_definitions.py
index b55569fab87a..4939fa65acb5 100644
--- a/sympy/physics/units/definitions/unit_definitions.py
+++ b/sympy/physics/units/definitions/unit_definitions.py
@@ -5,7 +5,7 @@
 from sympy.core.numbers import (Rational, pi)
 from sympy.core.singleton import S as S_singleton
 from sympy.physics.units.prefixes import kilo, mega, milli, micro, deci, centi, nano, pico, kibi, mebi, gibi, tebi, pebi, exbi
-from sympy.physics.units.quantities import Quantity
+from sympy.physics.units.quantities import PhysicalConstant, Quantity
 
 One = S_singleton.One
 
@@ -117,7 +117,7 @@
 ug.set_global_relative_scale_factor(micro, gram)
 
 # Atomic mass constant
-Da = dalton = amu = amus = atomic_mass_unit = atomic_mass_constant = Quantity("atomic_mass_constant")
+Da = dalton = amu = amus = atomic_mass_unit = atomic_mass_constant = PhysicalConstant("atomic_mass_constant")
 
 t = metric_ton = tonne = Quantity("tonne", abbrev="t")
 tonne.set_global_relative_scale_factor(mega, gram)
@@ -232,62 +232,62 @@
 #### CONSTANTS ####
 
 # Newton constant
-G = gravitational_constant = Quantity("gravitational_constant", abbrev="G")
+G = gravitational_constant = PhysicalConstant("gravitational_constant", abbrev="G")
 
 # speed of light
-c = speed_of_light = Quantity("speed_of_light", abbrev="c")
+c = speed_of_light = PhysicalConstant("speed_of_light", abbrev="c")
 
 # elementary charge
-elementary_charge = Quantity("elementary_charge", abbrev="e")
+elementary_charge = PhysicalConstant("elementary_charge", abbrev="e")
 
 # Planck constant
-planck = Quantity("planck", abbrev="h")
+planck = PhysicalConstant("planck", abbrev="h")
 
 # Reduced Planck constant
-hbar = Quantity("hbar", abbrev="hbar")
+hbar = PhysicalConstant("hbar", abbrev="hbar")
 
 # Electronvolt
-eV = electronvolt = electronvolts = Quantity("electronvolt", abbrev="eV")
+eV = electronvolt = electronvolts = PhysicalConstant("electronvolt", abbrev="eV")
 
 # Avogadro number
-avogadro_number = Quantity("avogadro_number")
+avogadro_number = PhysicalConstant("avogadro_number")
 
 # Avogadro constant
-avogadro = avogadro_constant = Quantity("avogadro_constant")
+avogadro = avogadro_constant = PhysicalConstant("avogadro_constant")
 
 # Boltzmann constant
-boltzmann = boltzmann_constant = Quantity("boltzmann_constant")
+boltzmann = boltzmann_constant = PhysicalConstant("boltzmann_constant")
 
 # Stefan-Boltzmann constant
-stefan = stefan_boltzmann_constant = Quantity("stefan_boltzmann_constant")
+stefan = stefan_boltzmann_constant = PhysicalConstant("stefan_boltzmann_constant")
 
 # Molar gas constant
-R = molar_gas_constant = Quantity("molar_gas_constant", abbrev="R")
+R = molar_gas_constant = PhysicalConstant("molar_gas_constant", abbrev="R")
 
 # Faraday constant
-faraday_constant = Quantity("faraday_constant")
+faraday_constant = PhysicalConstant("faraday_constant")
 
 # Josephson constant
-josephson_constant = Quantity("josephson_constant", abbrev="K_j")
+josephson_constant = PhysicalConstant("josephson_constant", abbrev="K_j")
 
 # Von Klitzing constant
-von_klitzing_constant = Quantity("von_klitzing_constant", abbrev="R_k")
+von_klitzing_constant = PhysicalConstant("von_klitzing_constant", abbrev="R_k")
 
 # Acceleration due to gravity (on the Earth surface)
-gee = gees = acceleration_due_to_gravity = Quantity("acceleration_due_to_gravity", abbrev="g")
+gee = gees = acceleration_due_to_gravity = PhysicalConstant("acceleration_due_to_gravity", abbrev="g")
 
 # magnetic constant:
-u0 = magnetic_constant = vacuum_permeability = Quantity("magnetic_constant")
+u0 = magnetic_constant = vacuum_permeability = PhysicalConstant("magnetic_constant")
 
 # electric constat:
-e0 = electric_constant = vacuum_permittivity = Quantity("vacuum_permittivity")
+e0 = electric_constant = vacuum_permittivity = PhysicalConstant("vacuum_permittivity")
 
 # vacuum impedance:
-Z0 = vacuum_impedance = Quantity("vacuum_impedance", abbrev='Z_0', latex_repr=r'Z_{0}')
+Z0 = vacuum_impedance = PhysicalConstant("vacuum_impedance", abbrev='Z_0', latex_repr=r'Z_{0}')
 
 # Coulomb's constant:
 coulomb_constant = coulombs_constant = electric_force_constant = \
-    Quantity("coulomb_constant", abbrev="k_e")
+    PhysicalConstant("coulomb_constant", abbrev="k_e")
 
 
 atmosphere = atmospheres = atm = Quantity("atmosphere", abbrev="atm")
diff --git a/sympy/physics/units/prefixes.py b/sympy/physics/units/prefixes.py
index 10e1bbcbf52d..0fe8fbe7dac3 100644
--- a/sympy/physics/units/prefixes.py
+++ b/sympy/physics/units/prefixes.py
@@ -137,7 +137,8 @@ def prefix_unit(unit, prefixes):
     for prefix_abbr, prefix in prefixes.items():
         quantity = Quantity(
                 "%s%s" % (prefix.name, unit.name),
-                abbrev=("%s%s" % (prefix.abbrev, unit.abbrev))
+                abbrev=("%s%s" % (prefix.abbrev, unit.abbrev)),
+                is_prefixed=True,
            )
         UnitSystem._quantity_dimensional_equivalence_map_global[quantity] = unit
         UnitSystem._quantity_scale_factors_global[quantity] = (prefix.scale_factor, unit)
diff --git a/sympy/physics/units/quantities.py b/sympy/physics/units/quantities.py
index 4edc9acdcd1b..4f18d3656a11 100644
--- a/sympy/physics/units/quantities.py
+++ b/sympy/physics/units/quantities.py
@@ -21,11 +21,13 @@ class Quantity(AtomicExpr):
     is_real = True
     is_number = False
     is_nonzero = True
+    is_physical_constant = False
     _diff_wrt = True
 
     def __new__(cls, name, abbrev=None, dimension=None, scale_factor=None,
                 latex_repr=None, pretty_unicode_repr=None,
                 pretty_ascii_repr=None, mathml_presentation_repr=None,
+                is_prefixed=False,
                 **assumptions):
 
         if not isinstance(name, Symbol):
@@ -63,6 +65,9 @@ def __new__(cls, name, abbrev=None, dimension=None, scale_factor=None,
         elif isinstance(abbrev, str):
             abbrev = Symbol(abbrev)
 
+        # HACK: These are here purely for type checking. They actually get assigned below.
+        cls._is_prefixed = is_prefixed
+
         obj = AtomicExpr.__new__(cls, name, abbrev)
         obj._name = name
         obj._abbrev = abbrev
@@ -70,6 +75,7 @@ def __new__(cls, name, abbrev=None, dimension=None, scale_factor=None,
         obj._unicode_repr = pretty_unicode_repr
         obj._ascii_repr = pretty_ascii_repr
         obj._mathml_repr = mathml_presentation_repr
+        obj._is_prefixed = is_prefixed
 
         if dimension is not None:
             # TODO: remove after deprecation:
@@ -80,6 +86,7 @@ def __new__(cls, name, abbrev=None, dimension=None, scale_factor=None,
             # TODO: remove after deprecation:
             with ignore_warnings(SymPyDeprecationWarning):
                 obj.set_scale_factor(scale_factor)
+
         return obj
 
     def set_dimension(self, dimension, unit_system="SI"):
@@ -119,6 +126,8 @@ def set_global_relative_scale_factor(self, scale_factor, reference_quantity):
         """
         from sympy.physics.units import UnitSystem
         scale_factor = sympify(scale_factor)
+        if isinstance(scale_factor, Prefix):
+            self._is_prefixed = True
         # replace all prefixes by their ratio to canonical units:
         scale_factor = scale_factor.replace(
             lambda x: isinstance(x, Prefix),
@@ -232,3 +241,13 @@ def convert_to(self, other, unit_system="SI"):
     def free_symbols(self):
         """Return free symbols from quantity."""
         return set()
+
+    @property
+    def is_prefixed(self):
+        """Whether or not the quantity is prefixed. Eg. `kilogram` is prefixed, but `gram` is not."""
+        return self._is_prefixed
+
+class PhysicalConstant(Quantity):
+    """Represents a physical constant, eg. `speed_of_light` or `avogadro_constant`."""
+
+    is_physical_constant = True
diff --git a/sympy/physics/units/systems/mks.py b/sympy/physics/units/systems/mks.py
index 116ab36f8555..4295254e8122 100644
--- a/sympy/physics/units/systems/mks.py
+++ b/sympy/physics/units/systems/mks.py
@@ -4,8 +4,8 @@
 MKS stands for "meter, kilogram, second".
 """
 
-from sympy.physics.units import UnitSystem, DimensionSystem
-from sympy.physics.units.definitions import G, Hz, J, N, Pa, W, c, g, kg, m, s
+from sympy.physics.units import UnitSystem
+from sympy.physics.units.definitions import gravitational_constant, hertz, joule, newton, pascal, watt, speed_of_light, gram, kilogram, meter, second
 from sympy.physics.units.definitions.dimension_definitions import (
     acceleration, action, energy, force, frequency, momentum,
     power, pressure, velocity, length, mass, time)
@@ -15,25 +15,32 @@
 dims = (velocity, acceleration, momentum, force, energy, power, pressure,
         frequency, action)
 
-units = [m, g, s, J, N, W, Pa, Hz]
+units = [meter, gram, second, joule, newton, watt, pascal, hertz]
 all_units = []
 
-# Prefixes of units like g, J, N etc get added using `prefix_unit`
+# Prefixes of units like gram, joule, newton etc get added using `prefix_unit`
 # in the for loop, but the actual units have to be added manually.
-all_units.extend([g, J, N, W, Pa, Hz])
+all_units.extend([gram, joule, newton, watt, pascal, hertz])
 
 for u in units:
     all_units.extend(prefix_unit(u, PREFIXES))
-all_units.extend([G, c])
+all_units.extend([gravitational_constant, speed_of_light])
 
 # unit system
-MKS = UnitSystem(base_units=(m, kg, s), units=all_units, name="MKS", dimension_system=dimsys_length_weight_time)
+MKS = UnitSystem(base_units=(meter, kilogram, second), units=all_units, name="MKS", dimension_system=dimsys_length_weight_time, derived_units={
+    power.name: watt,
+    time.name: second,
+    pressure.name: pascal,
+    length.name: meter,
+    frequency.name: hertz,
+    mass.name: kilogram,
+    force.name: newton,
+    energy.name: joule,
+    velocity.name: meter/second,
+    acceleration.name: meter/(second**2),
+})
 
 
 __all__ = [
-    'force', 'DimensionSystem', 'energy', 'Pa', 'MKS',
-    'dimsys_length_weight_time', 'Hz', 'power', 's', 'UnitSystem', 'units',
-    'mass', 'momentum', 'acceleration', 'G', 'J', 'N', 'pressure', 'W',
-    'all_units', 'c', 'kg', 'g', 'dims', 'prefix_unit', 'm', 'PREFIXES',
-    'length', 'frequency', 'u', 'time', 'action', 'velocity',
+    'MKS', 'units', 'all_units', 'dims',
 ]
diff --git a/sympy/physics/units/systems/mksa.py b/sympy/physics/units/systems/mksa.py
index cb68e59af232..76df5a80f79d 100644
--- a/sympy/physics/units/systems/mksa.py
+++ b/sympy/physics/units/systems/mksa.py
@@ -6,7 +6,7 @@
 
 from typing import List
 
-from sympy.physics.units.definitions import Z0, A, C, F, H, S, T, V, Wb, ohm
+from sympy.physics.units.definitions import Z0, ampere, coulomb, farad, henry, siemens, tesla, volt, weber, ohm
 from sympy.physics.units.definitions.dimension_definitions import (
     capacitance, charge, conductance, current, impedance, inductance,
     magnetic_density, magnetic_flux, voltage)
@@ -17,11 +17,12 @@
 dims = (voltage, impedance, conductance, current, capacitance, inductance, charge,
         magnetic_density, magnetic_flux)
 
-units = [A, V, ohm, S, F, H, C, T, Wb]
+units = [ampere, volt, ohm, siemens, farad, henry, coulomb, tesla, weber]
 
 all_units = []  # type: List[Quantity]
 for u in units:
     all_units.extend(prefix_unit(u, PREFIXES))
+all_units.extend(units)
 
 all_units.append(Z0)
 
@@ -40,4 +41,14 @@
     magnetic_flux=dict(length=2, mass=1, current=-1, time=-2),
 ))
 
-MKSA = MKS.extend(base=(A,), units=all_units, name='MKSA', dimension_system=dimsys_MKSA)
+MKSA = MKS.extend(base=(ampere,), units=all_units, name='MKSA', dimension_system=dimsys_MKSA, derived_units={
+    magnetic_flux.name: weber,
+    impedance.name: ohm,
+    current.name: ampere,
+    voltage.name: volt,
+    inductance.name: henry,
+    conductance.name: siemens,
+    magnetic_density.name: tesla,
+    charge.name: coulomb,
+    capacitance.name: farad,
+})
diff --git a/sympy/physics/units/systems/si.py b/sympy/physics/units/systems/si.py
index d483d56d790b..1e77b471443b 100644
--- a/sympy/physics/units/systems/si.py
+++ b/sympy/physics/units/systems/si.py
@@ -56,6 +56,7 @@
 for u in units:
     all_units.extend(prefix_unit(u, PREFIXES))
 
+all_units.extend(units)
 all_units.extend([mol, cd, K, lux])
 
 
@@ -71,7 +72,29 @@
     [information],
 )
 
-SI = MKSA.extend(base=(mol, cd, K), units=all_units, name='SI', dimension_system=dimsys_SI)
+SI = MKSA.extend(base=(mol, cd, K), units=all_units, name='SI', dimension_system=dimsys_SI, derived_units={
+    power.name: watt,
+    magnetic_flux.name: weber,
+    time.name: second,
+    impedance.name: ohm,
+    pressure.name: pascal,
+    current.name: ampere,
+    voltage.name: volt,
+    length.name: meter,
+    frequency.name: hertz,
+    inductance.name: henry,
+    temperature.name: kelvin,
+    amount_of_substance.name: mole,
+    luminous_intensity.name: candela,
+    conductance.name: siemens,
+    mass.name: kilogram,
+    magnetic_density.name: tesla,
+    charge.name: coulomb,
+    force.name: newton,
+    capacitance.name: farad,
+    energy.name: joule,
+    velocity.name: meter/second,
+})
 
 One = S.One
 
diff --git a/sympy/physics/units/tests/test_quantities.py b/sympy/physics/units/tests/test_quantities.py
index c9e5900d3d48..998b630eb7aa 100644
--- a/sympy/physics/units/tests/test_quantities.py
+++ b/sympy/physics/units/tests/test_quantities.py
@@ -11,7 +11,9 @@
 from sympy.functions.elementary.trigonometric import sin
 from sympy.integrals.integrals import integrate
 from sympy.physics.units import (amount_of_substance, area, convert_to, find_unit,
-                                 volume, kilometer, joule)
+                                 volume, kilometer, joule, molar_gas_constant,
+                                 vacuum_permittivity, elementary_charge, volt,
+                                 ohm)
 from sympy.physics.units.definitions import (amu, au, centimeter, coulomb,
     day, foot, grams, hour, inch, kg, km, m, meter, millimeter,
     minute, quart, s, second, speed_of_light, bit,
@@ -23,7 +25,7 @@
     energy
 )
 from sympy.physics.units.prefixes import PREFIXES, kilo
-from sympy.physics.units.quantities import Quantity
+from sympy.physics.units.quantities import PhysicalConstant, Quantity
 from sympy.physics.units.systems import SI
 from sympy.testing.pytest import XFAIL, raises, warns_deprecated_sympy
 
@@ -524,3 +526,34 @@ def test_issue_22819():
     assert tonne.convert_to(gram) == 1000000*gram
     assert dimsys_SI.get_dimensional_dependencies(area) == {'length': 2}
     assert Da.scale_factor == 1.66053906660000e-24
+
+
+def test_prefixed_property():
+    assert not meter.is_prefixed
+    assert not joule.is_prefixed
+    assert not day.is_prefixed
+    assert not second.is_prefixed
+    assert not volt.is_prefixed
+    assert not ohm.is_prefixed
+    assert centimeter.is_prefixed
+    assert kilometer.is_prefixed
+    assert kilogram.is_prefixed
+    assert pebibyte.is_prefixed
+
+def test_physics_constant():
+    from sympy.physics.units import definitions
+
+    for name in dir(definitions):
+        quantity = getattr(definitions, name)
+        if not isinstance(quantity, Quantity):
+            continue
+        if name.endswith('_constant'):
+            assert isinstance(quantity, PhysicalConstant), f"{quantity} must be PhysicalConstant, but is {type(quantity)}"
+            assert quantity.is_physical_constant, f"{name} is not marked as physics constant when it should be"
+
+    for const in [gravitational_constant, molar_gas_constant, vacuum_permittivity, speed_of_light, elementary_charge]:
+        assert isinstance(const, PhysicalConstant), f"{const} must be PhysicalConstant, but is {type(const)}"
+        assert const.is_physical_constant, f"{const} is not marked as physics constant when it should be"
+
+    assert not meter.is_physical_constant
+    assert not joule.is_physical_constant
diff --git a/sympy/physics/units/tests/test_unitsystem.py b/sympy/physics/units/tests/test_unitsystem.py
index 5c0729cfdd47..a04f3aabb627 100644
--- a/sympy/physics/units/tests/test_unitsystem.py
+++ b/sympy/physics/units/tests/test_unitsystem.py
@@ -63,3 +63,24 @@ def test_is_consistent():
     dimension_system = DimensionSystem([length, time])
     us = UnitSystem([m, s], dimension_system=dimension_system)
     assert us.is_consistent == True
+
+
+def test_get_units_non_prefixed():
+    from sympy.physics.units import volt, ohm
+    unit_system = UnitSystem.get_unit_system("SI")
+    units = unit_system.get_units_non_prefixed()
+    for prefix in ["giga", "tera", "peta", "exa", "zetta", "yotta", "kilo", "hecto", "deca", "deci", "centi", "milli", "micro", "nano", "pico", "femto", "atto", "zepto", "yocto"]:
+        for unit in units:
+            assert isinstance(unit, Quantity), f"{unit} must be a Quantity, not {type(unit)}"
+            assert not unit.is_prefixed, f"{unit} is marked as prefixed"
+            assert not unit.is_physical_constant, f"{unit} is marked as physics constant"
+            assert not unit.name.name.startswith(prefix), f"Unit {unit.name} has prefix {prefix}"
+    assert volt in units
+    assert ohm in units
+
+def test_derived_units_must_exist_in_unit_system():
+    for unit_system in UnitSystem._unit_systems.values():
+        for preferred_unit in unit_system.derived_units.values():
+            units = preferred_unit.atoms(Quantity)
+            for unit in units:
+                assert unit in unit_system._units, f"Unit {unit} is not in unit system {unit_system}"
diff --git a/sympy/physics/units/tests/test_util.py b/sympy/physics/units/tests/test_util.py
index 4e0bcfb83ef6..d5f464652cb8 100644
--- a/sympy/physics/units/tests/test_util.py
+++ b/sympy/physics/units/tests/test_util.py
@@ -120,6 +120,30 @@ def test_quantity_simplify():
     assert quantity_simplify(2**(foot/inch*kilo/1000)*inch) == 4096*foot/12
     assert quantity_simplify(foot**2*inch + inch**2*foot) == 13*foot**3/144
 
+def test_quantity_simplify_across_dimensions():
+    from sympy.physics.units.util import quantity_simplify
+    from sympy.physics.units import ampere, ohm, volt, joule, pascal, farad, second, watt, siemens, henry, tesla, weber, hour, newton
+
+    assert quantity_simplify(ampere*ohm, across_dimensions=True) == volt
+    assert quantity_simplify(6*ampere*ohm, across_dimensions=True) == 6*volt
+    assert quantity_simplify(volt/ampere, across_dimensions=True) == ohm
+    assert quantity_simplify(volt/ohm, across_dimensions=True) == ampere
+    assert quantity_simplify(joule/meter**3, across_dimensions=True) == pascal
+    assert quantity_simplify(farad*ohm, across_dimensions=True) == second
+    assert quantity_simplify(joule/second, across_dimensions=True) == watt
+    assert quantity_simplify(meter**3/second, across_dimensions=True) == meter**3/second
+    assert quantity_simplify(joule/second, across_dimensions=True) == watt
+
+    assert quantity_simplify(joule/coulomb, across_dimensions=True) == volt
+    assert quantity_simplify(volt/ampere, across_dimensions=True) == ohm
+    assert quantity_simplify(ampere/volt, across_dimensions=True) == siemens
+    assert quantity_simplify(coulomb/volt, across_dimensions=True) == farad
+    assert quantity_simplify(volt*second/ampere, across_dimensions=True) == henry
+    assert quantity_simplify(volt*second/meter**2, across_dimensions=True) == tesla
+    assert quantity_simplify(joule/ampere, across_dimensions=True) == weber
+
+    assert quantity_simplify(5*kilometer/hour, across_dimensions=True) == 25*meter/(18*second)
+    assert quantity_simplify(5*kilogram*meter/second**2, across_dimensions=True) == 5*newton
 
 def test_check_dimensions():
     x = symbols('x')
diff --git a/sympy/physics/units/unitsystem.py b/sympy/physics/units/unitsystem.py
index dd402260da90..eaf889bfdb0a 100644
--- a/sympy/physics/units/unitsystem.py
+++ b/sympy/physics/units/unitsystem.py
@@ -2,14 +2,16 @@
 Unit system for physical quantities; include definition of constants.
 """
 
-from typing import Dict as tDict
+from typing import Dict as tDict, Set as tSet
 
 from sympy.core.add import Add
 from sympy.core.function import (Derivative, Function)
 from sympy.core.mul import Mul
 from sympy.core.power import Pow
 from sympy.core.singleton import S
+from sympy.core.symbol import Symbol
 from sympy.physics.units.dimensions import _QuantityMapper
+from sympy.physics.units.quantities import Quantity
 
 from .dimensions import Dimension
 
@@ -26,7 +28,7 @@ class UnitSystem(_QuantityMapper):
 
     _unit_systems = {}  # type: tDict[str, UnitSystem]
 
-    def __init__(self, base_units, units=(), name="", descr="", dimension_system=None):
+    def __init__(self, base_units, units=(), name="", descr="", dimension_system=None, derived_units: tDict[Symbol, Quantity]={}):
 
         UnitSystem._unit_systems[name] = self
 
@@ -37,6 +39,7 @@ def __init__(self, base_units, units=(), name="", descr="", dimension_system=Non
         self._dimension_system = dimension_system
         self._units = tuple(set(base_units) | set(units))
         self._base_units = tuple(base_units)
+        self._derived_units = derived_units
 
         super().__init__()
 
@@ -57,7 +60,7 @@ def __str__(self):
     def __repr__(self):
         return '<UnitSystem: %s>' % repr(self._base_units)
 
-    def extend(self, base, units=(), name="", description="", dimension_system=None):
+    def extend(self, base, units=(), name="", description="", dimension_system=None, derived_units: tDict[Symbol, Quantity]={}):
         """Extend the current system into a new one.
 
         Take the base and normal units of the current system to merge
@@ -68,7 +71,7 @@ def extend(self, base, units=(), name="", description="", dimension_system=None)
         base = self._base_units + tuple(base)
         units = self._units + tuple(units)
 
-        return UnitSystem(base, units, name, description, dimension_system)
+        return UnitSystem(base, units, name, description, dimension_system, {**self._derived_units, **derived_units})
 
     def get_dimension_system(self):
         return self._dimension_system
@@ -121,6 +124,10 @@ def is_consistent(self):
         # test is performed in DimensionSystem
         return self.get_dimension_system().is_consistent
 
+    @property
+    def derived_units(self) -> tDict[Symbol, Quantity]:
+        return self._derived_units
+
     def get_dimensional_expr(self, expr):
         from sympy.physics.units import Quantity
         if isinstance(expr, Mul):
@@ -192,3 +199,9 @@ def _collect_factor_and_dimension(self, expr):
             return S.One, expr
         else:
             return expr, Dimension(1)
+
+    def get_units_non_prefixed(self) -> tSet[Quantity]:
+        """
+        Return the units of the system that do not have a prefix.
+        """
+        return set(filter(lambda u: not u.is_prefixed and not u.is_physical_constant, self._units))
diff --git a/sympy/physics/units/util.py b/sympy/physics/units/util.py
index 13125ac1ddc0..54a30039f923 100644
--- a/sympy/physics/units/util.py
+++ b/sympy/physics/units/util.py
@@ -11,9 +11,10 @@
 from sympy.core.sorting import ordered
 from sympy.core.sympify import sympify
 from sympy.matrices.common import NonInvertibleMatrixError
-from sympy.physics.units.dimensions import Dimension
+from sympy.physics.units.dimensions import Dimension, DimensionSystem
 from sympy.physics.units.prefixes import Prefix
 from sympy.physics.units.quantities import Quantity
+from sympy.physics.units.unitsystem import UnitSystem
 from sympy.utilities.iterables import sift
 
 
@@ -120,21 +121,24 @@ def get_total_scale_factor(expr):
     return expr_scale_factor * Mul.fromiter((1/get_total_scale_factor(u) * u) ** p for u, p in zip(target_units, depmat))
 
 
-def quantity_simplify(expr):
+def quantity_simplify(expr, across_dimensions: bool=False, unit_system="SI"):
     """Return an equivalent expression in which prefixes are replaced
     with numerical values and all units of a given dimension are the
-    unified in a canonical manner.
+    unified in a canonical manner by default. `across_dimensions` allows
+    for units of different dimensions to be simplified together.
 
     Examples
     ========
 
     >>> from sympy.physics.units.util import quantity_simplify
     >>> from sympy.physics.units.prefixes import kilo
-    >>> from sympy.physics.units import foot, inch
+    >>> from sympy.physics.units import foot, inch, joule, coulomb
     >>> quantity_simplify(kilo*foot*inch)
     250*foot**2/3
     >>> quantity_simplify(foot - 6*inch)
     foot/2
+    >>> quantity_simplify(5*joule/coulomb, across_dimensions=True)
+    5*volt
     """
 
     if expr.is_Atom or not expr.has(Prefix, Quantity):
@@ -154,6 +158,29 @@ def quantity_simplify(expr):
         ref = v[0]/v[0].scale_factor
         expr = expr.xreplace({vi: ref*vi.scale_factor for vi in v[1:]})
 
+    if across_dimensions:
+        # combine quantities of different dimensions into a single
+        # quantity that is equivalent to the original expression
+
+        unit_system = UnitSystem.get_unit_system(unit_system)
+        dimension_system: DimensionSystem = unit_system.get_dimension_system()
+        dim_expr = unit_system.get_dimensional_expr(expr)
+        dim_deps = dimension_system.get_dimensional_dependencies(dim_expr, mark_dimensionless=True)
+
+        target_dimension = None
+        for result_dim, result_deps in dimension_system.dimensional_dependencies.items():
+            if result_deps == dim_deps:
+                target_dimension = result_dim
+                break
+
+        if target_dimension is None:
+            # if we can't find a target dimension, we can't do anything. unsure how to handle this case.
+            return expr
+
+        target_unit = unit_system.derived_units.get(target_dimension)
+        if target_unit:
+            expr = convert_to(expr, target_unit, unit_system)
+
     return expr
 
 


Code review comments:
diff_hunk: @@ -26,7 +28,7 @@ class UnitSystem(_QuantityMapper):
 
     _unit_systems = {}  # type: tDict[str, UnitSystem]
 
-    def __init__(self, base_units, units=(), name="", descr="", dimension_system=None):
+    def __init__(self, base_units, units=(), name="", descr="", dimension_system=None, derived_units: tDict[Symbol, Quantity]={}):
path: sympy/physics/units/unitsystem.py
text: @user1:
What about mapping `tDict[Dimension, Quantity]` instead of the name of the dimension?

@author:
That might work, I'm not sure if `Dimension` can be used as a dictionary key.

@author:
This works, but unfortunately requires an extra dict lookup. I'll add a new commit

@user1:
> That might work, I'm not sure if `Dimension` can be used as a dictionary key.

There was a bug some time ago not allowing `Dimension` to have a hash (the object was mutable), so the code was developed in order not to use `Dimension` objects as dictionary keys. I think this has been since fixed.