Quick Start

Polymorphs, different crystalline forms of the same molecule, are a huge problem for those who need a single specific crystal form of the right properties for further use, such as in pharma. While it is often the case that the preferred polymorph can rationally be obtained by smart solvent choice, there are cases where the solvent can have no effect - and intermediate cases where it all depends on time, seeding, cooling rate... The app explains what's going on.


The app is based on the much-cited paper by Terry Threlfall, Crystallisation of Polymorphs: Thermodynamic Insight into the Role of Solvent, Organic Process R&D, 2000, 4, 384−390


Solubility Shift II Skew Metastable Width Start Concentration Cooled To
Crystallization outcome
//One universal basic required here to get things going once loaded
window.onload = function () {
    //restoreDefaultValues(); //Un-comment this if you want to start with defaults
//Any global variables go here

//Main is hard wired as THE place to start calculating when input changes
//It does no calculations itself, it merely sets them up, sends off variables, gets results and, if necessary, plots them.
function Main() {

    //Send all the inputs as a structured object
    //If you need to convert to, say, SI units, do it here!
    const inputs = {
        ConcShift: sliders.SlideConcShift.value,
        Skew: sliders.SlideSkew.value,
        Meta: sliders.SlideMeta.value,
        StartConc: sliders.SlideStartConc.value,
        Cooling: sliders.SlideCooling.value,

    //Send inputs off to CalcIt where the names are instantly available
    //Get all the resonses as an object, result
    const result = CalcIt(inputs)

    //Set all the text box outputs
    document.getElementById('Comments').value = result.Comments
    //Do all relevant plots by calling plotIt - if there's no plot, nothing happens
    //plotIt is part of the app infrastructure in app.new.js
    if (result.plots) {
        for (let i = 0; i < result.plots.length; i++) {
            plotIt(result.plots[i], result.canvas[i]);

    //You might have some other stuff to do here, but for most apps that's it for CalcIt!

//Here's the app calculation
//The inputs are just the names provided - their order in the curly brackets is unimportant!
//By convention the input values are provided with the correct units within Main
function CalcIt({ Skew, ConcShift, Meta, StartConc, Cooling }) {

    let ICurve = [], IICurve = [], IDCurve = [], IIDCurve = [], IVal, IIVal, minII = 9999
    let CCurve = []

    for (let t = 0; t <= 1.005; t += 0.01) {
        IVal = 9 * Math.pow(t, 2)
        ICurve.push({ x: Math.min(t, 1), y: IVal })
        IDCurve.push({ x: Math.min(t, 1), y: IVal + Meta })
        IIVal =  ConcShift - Skew *(1-t) + 9 * Math.pow(t, 2)
        IICurve.push({ x: Math.min(t, 1), y: IIVal })
        IIDCurve.push({ x: Math.min(t, 1), y: IIVal + Meta })
        minII = Math.min(IIVal, minII)
    let offSet = 1
    if (minII < 1) { offSet = 1 - minII }
    for (let i = 0; i < ICurve.length; i++) {
        ICurve[i].y += offSet
        IICurve[i].y += offSet
        IDCurve[i].y += offSet
        IIDCurve[i].y += offSet
    StartConc += offSet
    //Now check for crossings

    const gotI = testCross(offSet,0,9,StartConc,Cooling)
    const gotID = testCross(offSet+Meta,0,9,StartConc,Cooling)
    const gotII = testCross(offSet+ConcShift,Skew,9,StartConc,Cooling)
    const gotIID= testCross(offSet+ConcShift+Meta,Skew,9,StartConc,Cooling)

    CCurve.push({ x: 1, y: StartConc })
    CCurve.push({ x: Cooling, y: StartConc })
    let Comments=""
    if (!gotI) Comments="No crystals"
    if (gotI && ! gotII) Comments="Slow appearance of I"
    if (gotI &&  gotID && ! gotII) Comments="Lots of I"
    if (gotI &&  gotID &&  gotII && !gotIID) Comments="Lots of I, despite being in zone II"
    if (gotI &&  gotII &&  gotII && gotIID) Comments="Probably II, assuming I is slow to nucleate"
    if (!gotI && gotII) Comments="Medium appearance of II"
    if (gotI && !gotID && gotII && !gotIID) Comments="Medium appearance of II"
    if (gotI && !gotID && gotII && gotIID) Comments="Fast appearance of II"

    //Now set up all the graphing data detail by detail.
    const prmap = {
        plotData: [ICurve, IDCurve, IICurve, IIDCurve, CCurve], //An array of 1 or more datasets
        lineLabels: ["I Sol", "I meta", "II Sol", "II meta", "Cooled To"], //An array of labels for each dataset
        dottedLine: [false, true, false, true, false],
        colors: ['#edc240', '#edc240', '#afd8f8', '#afd8f8', "red"],
        xLabel: "T&rel", //Label for the x axis, with an & to separate the units
        yLabel: "C&rel", //Label for the y axis, with an & to separate the units
        y2Label: null, //Label for the y2 axis, null if not needed
        yAxisL1R2: [], //Array to say which axis each dataset goes on. Blank=Left=1
        logX: false, //Is the x-axis in log form?
        xTicks: undefined, //We can define a tick function if we're being fancy
        logY: false, //Is the y-axis in log form?
        yTicks: undefined, //We can define a tick function if we're being fancy
        legendPosition: 'top', //Where we want the legend - top, bottom, left, right
        xMinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        yMinMax: [, Math.floor(IDCurve[IDCurve.length - 1].y)], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        y2MinMax: [,], //Set min and max, e.g. [-10,100], leave one or both blank for auto
        xSigFigs: 'F3', //These are the sig figs for the Tooltip readout. A wide choice!
        ySigFigs: 'F3', //F for Fixed, P for Precision, E for exponential

    //Now we return everything - text boxes, plot and the name of the canvas, which is 'canvas' for a single plot

    return {
        Comments: Comments,
        plots: [prmap],
        canvas: ['canvas'],


function testCross(A,B,C,Y,X){
    return A-B*(1-X)+C*X*X < Y

Necessary background to the app

We have two crystalline forms, polymorphs, I and II where I is a higher melting point and therefore lower solubility and, naively, the most stable form of the crystal which will always appear after infinite time. However, these are enantiotropic1, which means that they can interconvert in the solid phase if II happens to be more stable at a given temperature.

The default values of the app set up the case where the solubility curves cross at a practical temperature (neither the temperature T nor concentration C has units - we're just looking at principles) and below that crossing temperature, TX, II becomes the more stable form. To see how this might arise from a combination of MPts, enthalpies of fusion and virtual heat capacities, see the Ideal Polymorphs app.

If you took the trouble to get some crystals then held the system for a long time in the presence of the solvent, the final form will be I if T >T X and II if T < TX. Here we are interested in what would happen in normal timescales of practical crystallization.

There is one more assumption - that I is, for whatever reasons, slower to nucleate than II. The point of the app is that in many cases the assumption is irrelevant because for many situations, irrespective of solvent, seeds etc. the outcome is certain - you know that you will get I or II.

Why do we care? People like me with an interest in solubility science emphasise that much of crystallization science can be explained via specific solvent-solute interactions. It is often the case that the appearance of a specific polymorph is controlled by solvent interactions. The point of Threlfall's paper is that although this is often the case, there are situations where the outcome is independent of solvent interactions. That's what the app demonstrates

The graphs

Every solute has a Solubility curve, i.e. saturated concentration versus temperature. The solubility curves for I and II are the solid lines. I is less soluble than II at (relatively) higher temperatures, and you can change their relative curves via the Solubility Shift II slider. However, for reasons described in the Ideal Polymorphs app, these solubility curves can have a Skew so that II becomes less soluble (thermodynamically more stable) at T < TX.

You start crystallization with a Start Concentration and Cool To a specific (relative) temperature. Assuming, first, that you cool to below the saturation temperature of I you will expect to obtain crystals2. However, as is well-known, it can take a long time for crystals to appear, even if you add a seed. If you keep cooling so you are more and more supersaturated, you eventually reach a temperature where crystallization is more-or-less instant. The zone where the supersaturation increases from 1.01 (slow crystallization) to, say, 1.3 (rapid) is the metastable zone and is shown with the dotted lines. You can change the Metastable Width with the slider; for simplicity each polymorph has the same width though in reality they might be different.

The point of Threlfall's paper is that if you cool into any portion of the higher concentration zone between the I and II solid lines, you will get I separating out. If the metastable zone of I is also below II then you will get lots of I if you cool to this point. Irrespective of solvent interactions, you will always get I. Similarly, if you start off at lower concentrations, then you cool into the region between Zones II and I, II falls out, in agreement with the thermodynamics of being the more stable state.

What's interesting are the more complex regions, closer to the crossing point. If, as in the paper, you assume that I is slower to nucleate, then II will often be the preferred form for predictable regions even where I should be the thermodynamic outcome. In these areas, where the solvent has an effect on relative nucleation rates, the outcome might be controlled by the solvent.

Hopefully you've already been playing with the app and reading my conclusions about the outcome. There may be zones where you disagree. That might be because I've not programmed that case, in which case, email me to ask it to be fixed. Or it might be one of those zones where Threlfall expresses a likelihood rather than a certainty. The details of cooling rates, waiting times, pre-seeding will all influence the outcome in those zones. The point of the app is to highlight the "safe" zones where the outcome is certain, and to alert you to the fact that working in the intermediate zones will be harder.

Polymorphs are confusing

Threlfall says [I] recently undertook to crystallise 20 well-known pharmaceutical polymorphic pairs, using apparently well-described recipes, often from well-respected groups, but failed to obtain the expected outcome in respect of the form obtained in 10 of these cases. 20+ years later, the situation still remains confusing. Even for the app's apparently simple case of two polymorphs where we have perfect knowledge of their solubility and metastable curves, it's complicated. In the real world where we have more polymorphs and less accurate knowledge, life is even tougher. But let's grab whatever certainties are available to us. If you want a relatively simple life with polymorphs, aim to crystallize in those zones where the outcome is less uncertain.


Monotropic polymorphs cannot inter-convert in the solid state, though you can semi-dissolve the less stable form and over time it will convert to the more stable. With enantiotropic polymorphs, which have a crossing point in their temperature curves, interconversion to the more stable state is possible, though even here a bit of help from a trace of solvent might speed things up. Which is another way of saying that if you crystallize slowly enough you will always get the thermodynamic polymorph.


Once you start to get crystals, you need to keep cooling down to a final "collection temperature" as your concentration has decreased. The assumption in the paper and the app is that you use a "smart" algorithm to be able to stay in an appropriate zone long enough that even if you then stray into a "wrong" zone, you have so swamped the system with seeds and crystals of your preferred form that the other doesn't stand a chance.