Way back in 2017 we released our Price-Per-Unit tool. We developed this as a new way of identifying where prescribing costs could be reduced without changing the medication the patient was taking. You can read more about how we built the tool on our blog, and our paper in BMJ Open describes what we found from developing it.
The tool works to find the lowest-cost medicine in two different ways:
- It calculates the “price-per-unit” (e.g. the cost per tablet, or millilitre) for all the different generic and brand versions of the drug that are available. It then calculates the saving if everyone prescribed at the same price-per-unit as those in the lowest 10%.
- It also adds in “formulation swaps”, e.g. if both omeprazole 20mg tablets AND omeprazole 20mg capsules were available, the tool would consider the price-per-unit for brands and generics for both tablets and capsules.
Step 1: no more manual curation
2017 is a long time ago, and we’ve learnt a lot about how to work with prescribing data since then. For example, the original formulation swaps list was created manually in Google Sheets. This requires a lot of time and effort to keep up-to-date, and given the small number of people in the team wasn’t a priority, and so if a new product was released (e.g. atorvastatin 20mg capsules) the tool won’t know to match it to the cheaper tablets.
One thing we’ve learned while developing OpenPrescribing is to reduce reliance on manually listing drugs when making measures. We’ve written before about how we’ve changed measure definitions for areas such as statins and emollients. We wondered if we could do a similar thing to identify formulation swaps.
As a result, we decided it was time to review how the tool is built, in order to make it better for users.
We turned to the Dictionary of Medicines + Devices (dm+d), which has a number of fields which we could use to build an algorithm to identify suitable swaps. We experimented with a few different fields which were available in the dictionary of medicines and devices, and came up with the following criteria for a suitable swap:
- Must be the same chemical substance (or substances), and not include other substances
- Must be the same strength (for all chemical substances)
- Must be the same route (e.g. oral, rectal, injection)
- Must not have bioavailability issues if prescribed generically or with a different formulation (e.g. exclude phenytoin, where tablet and capsule have different properties)
- If not oral, must be same “size”, e.g. Morphine 10mg/2ml and 5mg/1ml aren’t equivalent, even though they’re the same strength (5mg/ml)
- Swaps between “normal release” and “modified-release” aren’t allowed
In addition, following further experimentation, we added the following logic:
- If an inhaler, we describe the type of device, so that e.g. Metered Dose Inhalers (MDIs) don’t match with Dry Powder Inhalers (DPIs)
- If it is preservative free, it won’t match with non-preservative free preparations
- If it’s a skin preparation, it has to be the same type of formulation (e.g. shower gels and skin preparations are not matched).
We use these fields to create a string, and only where this string completely matches other preparations will a formulation swap be matched.
For example the string for Ramipril 10mg tablets
(0205051R0AAANAN
) is
386872004|10|258684004|NULL|NULL|26643006|1|NULL|NULL|NULL|NULL|NULL
(click to show details of each field).
Field Name | Field Description | Value | Description |
---|---|---|---|
ing |
Chemical ingredient | 386872004 | Ramipril |
basis_strnt |
How is ingredient strength calculated? | 1 | Ingredient Substance |
strnt_nmrtr_val |
Strength value numerator | 10 | |
strnt_nmrtr_uom |
Strength value numerator unit | 258684004 | mg |
strnt_dnmtr_val |
Strength value denominator | NULL | |
strnt_dnmtr_uom |
Strength value denominator unit | NULL | |
route |
Drug route | 26643006 | Oral |
pres_stat |
Prescribing status | 1 | Valid as a prescribable product |
udfs |
Unit dose form size | NULL | (Used in non-oral preps, e.g., ampoule size) |
formroute.descr |
Inhaler type | NULL | (Used in inhalers, e.g., dry-powder) |
formroute.descr LIKE '%modified-release%' |
Is product modified-release | NULL | |
pres_f = TRUE OR nm contains "preservative-free" |
Is product preservative free | NULL | pres_f flag isn’t always correct, hence text check |
route.descr LIKE '%cutaneous%' |
Type of formulation | NULL | Only for skin preparations |
NOTE: this doesn’t contain the drug form, i.e. capsule, so it will match to any other generic which matches the whole string. In the case above, we get this:
In the case of ramipril 10mg tablets, the only product from the sample selected that also matches the whole string will be ramipril 10mg capsules.
If you really want to get nerdy, click to see the full SQL that makes this happen:
WITH drugs AS (
SELECT
vmp.id,
nm,
bnf_code,
pres_stat,
droute.route,
pres_f,
sug_f,
CASE WHEN formroute.descr LIKE 'solutioninfusion%' then 'solutioninfusion' -- simplify multiple infusion routes as 'infusion'
WHEN formroute.descr LIKE 'solutioninjection%' then 'solutioninjection' -- simplify multiple injection routes as 'injection'
ELSE formroute.descr END AS formroute,
udfs,
form.descr AS form,
STRING_AGG(
CONCAT(
COALESCE(COALESCE(CAST (bs_subid AS STRING), (CAST(ing AS STRING))), 'NULL'), '|', -- ingredient code
COALESCE(CAST(strnt_nmrtr_val AS STRING), 'NULL'), '|', -- strength numerator value
COALESCE(CAST(strnt_nmrtr_uom AS STRING), 'NULL'), '|', -- strength numerator unit
COALESCE(CAST(strnt_dnmtr_val AS STRING), 'NULL'), '|', -- strength denominator value
COALESCE(CAST(strnt_dnmtr_uom AS STRING), 'NULL'), '|', -- strength denominator unit
COALESCE(CAST(droute.route AS STRING), 'NULL'), '|', -- route
COALESCE(CAST(pres_stat AS STRING), 'NULL'), '|', -- prescribing suitable for primary care value
COALESCE(CAST(pres_f AS STRING), 'NULL'), '|', -- preservative free flag
COALESCE(CASE WHEN formroute.descr NOT LIKE '%.oral%' THEN CAST(udfs AS STRING) ELSE 'NULL' END, 'NULL'), '|', -- if not oral meds, then ensure unit doses are the same (e.g. injections), i.e. so that 10mg/5ml isn't offered for 2mg/1ml
COALESCE(CASE WHEN droute.route = 18679011000001101 THEN CAST(formroute.cd AS STRING) ELSE 'NULL' END, 'NULL'), '|', -- if inhalation (route = 18679011000001101), then include type of device (e.g. dry powder, pressurized) so that device swaps aren't offered
CASE WHEN pres_f = TRUE OR LOWER(nm) LIKE '%preservative free%' THEN 'pf' ELSE 'NULL' END, '|', -- add 'preservative free' flag so that it doesn't match non pf products. Some pf eye drops don't have correct flag so have included name
CASE WHEN LOWER(formroute.descr) LIKE '%modified-release%' THEN 'MR' ELSE 'NULL' END, '|', -- add 'modified release' flag on match string, so that non modified-release preps aren't matched with standard release
CASE WHEN LOWER(route.descr) LIKE '%cutaneous%' THEN LEFT(formroute.descr, STRPOS(formroute.descr, '.') - 1) ELSE 'NULL' END -- add type of formulation to cutaneous preps, so that e.g. shower gels aren't offered for skin gels
),
','
ORDER BY ing, basis_strnt
) AS vpi_string
FROM
dmd.vpi AS vpi
INNER JOIN
dmd.vmp AS vmp ON vpi.vmp = vmp.id
INNER JOIN
dmd.droute AS droute ON vmp.id = droute.vmp
INNER JOIN
dmd.route AS route ON route.cd = droute.route
INNER JOIN
dmd.ont AS ont ON vmp.id = ONT.vmp
INNER JOIN
dmd.ontformroute AS formroute ON ont.form = formroute.cd
INNER JOIN
dmd.dform AS dform ON vmp.id = dform.vmp
INNER JOIN
dmd.form AS form ON dform.form = form.cd
WHERE bnf_code IS NOT NULL --make sure all drugs have BNF code
AND SUBSTR(bnf_code,10,2) = 'AA' --make sure all generic codes
AND (non_avail != 1 OR non_avail is NULL) -- make sure all drugs are available
AND strnt_nmrtr_val IS NOT NULL -- make sure all drugs have a strength
AND pres_stat = 1 -- must be "suitable for prescribing in primary care", e.g. no cautions on switching preparations
GROUP BY
vmp.id, nm, bnf_code, pres_stat, pres_f, sug_f, route, formroute, udfs, form
)
SELECT DISTINCT
drugs_1.bnf_code AS Code,
drugs_1.nm AS Name,
drugs_2.bnf_code AS Alternative_code,
CASE WHEN drugs_1.pres_f IS TRUE THEN concat(drugs_1.form, ' preservative free')
WHEN drugs_1.sug_f IS TRUE THEN concat(drugs_1.form, ' sugar free')
ELSE drugs_1.form END AS Formulation,
drugs_2.nm AS Alternative_name,
CASE WHEN drugs_2.pres_f IS TRUE THEN concat(drugs_2.form, ' preservative free')
WHEN drugs_2.sug_f IS TRUE THEN concat(drugs_2.form, ' sugar free') ELSE drugs_2.form END AS Alternative_formulation
FROM
drugs AS drugs_1
INNER JOIN
drugs AS drugs_2
ON drugs_1.vpi_string = drugs_2.vpi_string -- only where strings are the same, i.e. same ingredients, strength, and route
WHERE
drugs_1.id != drugs_2.id -- so don't get duplication
AND
drugs_1.bnf_code != drugs_2.bnf_code -- remove duplication where two different VMP types have same BNF code (e.g. solution/suspension)
Step 2: improving generic to brand links
We originally used the BNF code map to link brands and generics together, as most drugs have a matching hierarchy (for more details see our blog from 2017). Since then there have been changes to some products, such as some eye drops (e.g. hypromellose) and emollients (e.g. Zerobase), which now appear in the “appliances” part of the BNF code list.
Unlike drugs, appliance codes are an 11 digit non-hierarchical number. For example Zerobase Cream used to have a BNF code of 130201000
BBKG
CI
, meaning it would match to generic Liquid Paraffin Cream 11%
(130201000
AACI
CI
). It was changed to an appliance code of 21220000214
, which doesn’t have a matching generic code.
However the dm+d does give us a map between generic (known as virtual medicinal product) and brand (actual medicinal product), even where they don’t share the same BNF code hierarchy. By using this we were able to match more brands to generics. In the table below, several brands of hypromellose 0.3% eye drops
can be identified, despite not having the 1108010F0____AA
code structure:
AMP (brand) name | AMP BNF code | VMP (generic) name | VMP BNF code |
---|---|---|---|
Mandanol eye drops | 21300000114 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Lumecare Hypromellose 0.3% eye drops | 21300000117 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Xailin Hydrate 0.3% eye drops | 21300000169 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Ocufresh Hypromellose 0.3% eye drops | 21300000188 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Teardew 0.3% eye drops | 21300000308 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
AaproMel 0.3% eye drops | 21300000937 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
AacuLose Hypromellose 0.3% eye drops | 21300000950 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Ocufresh-C 0.3% eye drops | 21300000979 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Puroptics Hypromellose 0.3% eye drops | 21300000985 |
Hypromellose 0.3% eye drops | 1108010F0AAAAAA |
Step 3: discounts and housekeeping
We’ve also harmonised how we calculate discount deductions for medicines and appliances with our Price Concessions tool, meaning that the savings shown now fully reflect the recent changes to the discount system for pharmacy contractors. You can read more about the changes we made to Price Concessions in our 2024 blog.
Conclusion
As always in a big change like this, testing uncovers some expected issues. One we encountered was that Blood Glucose Testing Strips disappeared from the tool, so we had to deploy a fix to get them back
Hopefully you’ll find all our changes useful, and that the PPU is now better than ever. However, as always, if you find anything that you don’t think quite matches, seems to be missing, or doesn’t work properly please let us know.