diff --git a/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb b/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb index 615a12ec0..452763924 100644 --- a/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb +++ b/examples/notebooks/battery_parameterisation/echem_identification_pitfalls.ipynb @@ -32,8 +32,9 @@ "\n", "import pybop\n", "\n", - "go = pybop.plot.PlotlyManager().go\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "go = pybop.plot.backends.PlotlyManager().go\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb b/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb index dce5640a7..9d992aa2d 100644 --- a/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb +++ b/examples/notebooks/battery_parameterisation/ecm_monte_carlo_sampling.ipynb @@ -46,7 +46,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb index 65f4623b6..df18cdc56 100644 --- a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb +++ b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb @@ -48,7 +48,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb b/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb index 99724f587..0c42cf490 100644 --- a/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb +++ b/examples/notebooks/battery_parameterisation/ecm_scipy_constraints.ipynb @@ -31,7 +31,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb b/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb index adc764f25..190a5d569 100644 --- a/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb +++ b/examples/notebooks/battery_parameterisation/electrode_balancing.ipynb @@ -29,7 +29,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb b/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb index 7cd0c65b0..9509c47cf 100644 --- a/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb +++ b/examples/notebooks/battery_parameterisation/lgm50_pulse_validation.ipynb @@ -32,8 +32,9 @@ "\n", "import pybop\n", "\n", - "go = pybop.plot.PlotlyManager().go\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "go = pybop.plot.backends.PlotlyManager().go\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb b/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb index 593acc012..e649fb54b 100644 --- a/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb +++ b/examples/notebooks/battery_parameterisation/pouch_cell_identification.ipynb @@ -30,8 +30,9 @@ "\n", "import pybop\n", "\n", - "go = pybop.plot.PlotlyManager().go\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "go = pybop.plot.backends.PlotlyManager().go\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb b/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb index 3cab87a0d..4fc281242 100644 --- a/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb +++ b/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb @@ -29,6 +29,7 @@ "\n", "import pybop\n", "\n", + "pybop.plot.use_backend(\"matplotlib\")\n", "np.random.seed(8) # users can remove this line" ] }, @@ -210,708 +211,708 @@ "showlegend": false, "type": "scatter", "x": [ - 0.0, - 10.0, - 20.0, - 30.0, - 40.0, - 50.0, - 60.0, - 70.0, - 80.0, - 90.0, - 100.0, - 110.0, - 120.0, - 130.0, - 140.0, - 150.0, - 160.0, - 170.0, - 180.0, - 190.0, - 200.0, - 210.0, - 220.0, - 230.0, - 240.0, - 250.0, - 260.0, - 270.0, - 280.0, - 290.0, - 300.0, - 310.0, - 320.0, - 330.0, - 340.0, - 350.0, - 360.0, - 370.0, - 380.0, - 390.0, - 400.0, - 410.0, - 420.0, - 430.0, - 440.0, - 450.0, - 460.0, - 470.0, - 480.0, - 490.0, - 500.0, - 510.0, - 520.0, - 530.0, - 540.0, - 550.0, - 560.0, - 570.0, - 580.0, - 590.0, - 600.0, - 610.0, - 620.0, - 630.0, - 640.0, - 650.0, - 660.0, - 670.0, - 680.0, - 690.0, - 700.0, - 710.0, - 720.0, - 730.0, - 740.0, - 750.0, - 760.0, - 770.0, - 780.0, - 790.0, - 800.0, - 810.0, - 820.0, - 830.0, - 840.0, - 850.0, - 860.0, - 870.0, - 880.0, - 890.0, - 900.0, - 910.0, - 920.0, - 930.0, - 940.0, - 950.0, - 960.0, - 970.0, - 980.0, - 990.0, - 1000.0, - 1010.0, - 1020.0, - 1030.0, - 1040.0, - 1050.0, - 1060.0, - 1070.0, - 1080.0, - 1090.0, - 1100.0, - 1110.0, - 1120.0, - 1130.0, - 1140.0, - 1150.0, - 1160.0, - 1170.0, - 1180.0, - 1190.0, - 1200.0, - 1210.0, - 1220.0, - 1230.0, - 1240.0, - 1250.0, - 1260.0, - 1270.0, - 1280.0, - 1290.0, - 1300.0, - 1310.0, - 1320.0, - 1330.0, - 1340.0, - 1350.0, - 1360.0, - 1370.0, - 1380.0, - 1390.0, - 1400.0, - 1410.0, - 1420.0, - 1430.0, - 1440.0, - 1450.0, - 1460.0, - 1470.0, - 1480.0, - 1490.0, - 1500.0, - 1510.0, - 1520.0, - 1530.0, - 1540.0, - 1550.0, - 1560.0, - 1570.0, - 1580.0, - 1590.0, - 1600.0, - 1610.0, - 1620.0, - 1630.0, - 1640.0, - 1650.0, - 1660.0, - 1670.0, - 1680.0, - 1690.0, - 1700.0, - 1710.0, - 1720.0, - 1730.0, - 1740.0, - 1750.0, - 1760.0, - 1770.0, - 1780.0, - 1790.0, - 1800.0, - 1810.0, - 1820.0, - 1830.0, - 1840.0, - 1850.0, - 1860.0, - 1870.0, - 1880.0, - 1890.0, - 1900.0, - 1910.0, - 1920.0, - 1930.0, - 1940.0, - 1950.0, - 1960.0, - 1970.0, - 1980.0, - 1990.0, - 2000.0, - 2010.0, - 2020.0, - 2030.0, - 2040.0, - 2050.0, - 2060.0, - 2070.0, - 2080.0, - 2090.0, - 2100.0, - 2110.0, - 2120.0, - 2130.0, - 2140.0, - 2150.0, - 2160.0, - 2170.0, - 2180.0, - 2190.0, - 2200.0, - 2210.0, - 2220.0, - 2230.0, - 2240.0, - 2250.0, - 2260.0, - 2270.0, - 2280.0, - 2290.0, - 2300.0, - 2310.0, - 2320.0, - 2330.0, - 2340.0, - 2350.0, - 2360.0, - 2370.0, - 2380.0, - 2390.0, - 2400.0, - 2410.0, - 2420.0, - 2430.0, - 2440.0, - 2450.0, - 2460.0, - 2470.0, - 2480.0, - 2490.0, - 2500.0, - 2510.0, - 2520.0, - 2530.0, - 2540.0, - 2550.0, - 2560.0, - 2570.0, - 2580.0, - 2590.0, - 2600.0, - 2610.0, - 2620.0, - 2630.0, - 2640.0, - 2650.0, - 2660.0, - 2670.0, - 2680.0, - 2690.0, - 2700.0, - 2710.0, - 2720.0, - 2730.0, - 2740.0, - 2750.0, - 2760.0, - 2770.0, - 2780.0, - 2790.0, - 2800.0, - 2810.0, - 2820.0, - 2830.0, - 2840.0, - 2850.0, - 2860.0, - 2870.0, - 2880.0, - 2890.0, - 2900.0, - 2910.0, - 2920.0, - 2930.0, - 2940.0, - 2950.0, - 2960.0, - 2970.0, - 2980.0, - 2990.0, - 3000.0, - 3010.0, - 3020.0, - 3030.0, - 3040.0, - 3050.0, - 3060.0, - 3070.0, - 3080.0, - 3090.0, - 3100.0, - 3110.0, - 3120.0, - 3130.0, - 3140.0, - 3150.0, - 3160.0, - 3170.0, - 3180.0, - 3190.0, - 3200.0, - 3210.0, - 3220.0, - 3230.0, - 3240.0, - 3250.0, - 3260.0, - 3270.0, - 3280.0, - 3290.0, - 3300.0, - 3310.0, - 3320.0, - 3330.0, - 3340.0, - 3350.0, - 3360.0, - 3370.0, - 3380.0, - 3390.0, - 3400.0, - 3410.0, - 3420.0, - 3430.0, - 3440.0, - 3450.0, - 3460.0, - 3470.0, - 3480.0, - 3490.0, - 3500.0, - 3500.0, - 3490.0, - 3480.0, - 3470.0, - 3460.0, - 3450.0, - 3440.0, - 3430.0, - 3420.0, - 3410.0, - 3400.0, - 3390.0, - 3380.0, - 3370.0, - 3360.0, - 3350.0, - 3340.0, - 3330.0, - 3320.0, - 3310.0, - 3300.0, - 3290.0, - 3280.0, - 3270.0, - 3260.0, - 3250.0, - 3240.0, - 3230.0, - 3220.0, - 3210.0, - 3200.0, - 3190.0, - 3180.0, - 3170.0, - 3160.0, - 3150.0, - 3140.0, - 3130.0, - 3120.0, - 3110.0, - 3100.0, - 3090.0, - 3080.0, - 3070.0, - 3060.0, - 3050.0, - 3040.0, - 3030.0, - 3020.0, - 3010.0, - 3000.0, - 2990.0, - 2980.0, - 2970.0, - 2960.0, - 2950.0, - 2940.0, - 2930.0, - 2920.0, - 2910.0, - 2900.0, - 2890.0, - 2880.0, - 2870.0, - 2860.0, - 2850.0, - 2840.0, - 2830.0, - 2820.0, - 2810.0, - 2800.0, - 2790.0, - 2780.0, - 2770.0, - 2760.0, - 2750.0, - 2740.0, - 2730.0, - 2720.0, - 2710.0, - 2700.0, - 2690.0, - 2680.0, - 2670.0, - 2660.0, - 2650.0, - 2640.0, - 2630.0, - 2620.0, - 2610.0, - 2600.0, - 2590.0, - 2580.0, - 2570.0, - 2560.0, - 2550.0, - 2540.0, - 2530.0, - 2520.0, - 2510.0, - 2500.0, - 2490.0, - 2480.0, - 2470.0, - 2460.0, - 2450.0, - 2440.0, - 2430.0, - 2420.0, - 2410.0, - 2400.0, - 2390.0, - 2380.0, - 2370.0, - 2360.0, - 2350.0, - 2340.0, - 2330.0, - 2320.0, - 2310.0, - 2300.0, - 2290.0, - 2280.0, - 2270.0, - 2260.0, - 2250.0, - 2240.0, - 2230.0, - 2220.0, - 2210.0, - 2200.0, - 2190.0, - 2180.0, - 2170.0, - 2160.0, - 2150.0, - 2140.0, - 2130.0, - 2120.0, - 2110.0, - 2100.0, - 2090.0, - 2080.0, - 2070.0, - 2060.0, - 2050.0, - 2040.0, - 2030.0, - 2020.0, - 2010.0, - 2000.0, - 1990.0, - 1980.0, - 1970.0, - 1960.0, - 1950.0, - 1940.0, - 1930.0, - 1920.0, - 1910.0, - 1900.0, - 1890.0, - 1880.0, - 1870.0, - 1860.0, - 1850.0, - 1840.0, - 1830.0, - 1820.0, - 1810.0, - 1800.0, - 1790.0, - 1780.0, - 1770.0, - 1760.0, - 1750.0, - 1740.0, - 1730.0, - 1720.0, - 1710.0, - 1700.0, - 1690.0, - 1680.0, - 1670.0, - 1660.0, - 1650.0, - 1640.0, - 1630.0, - 1620.0, - 1610.0, - 1600.0, - 1590.0, - 1580.0, - 1570.0, - 1560.0, - 1550.0, - 1540.0, - 1530.0, - 1520.0, - 1510.0, - 1500.0, - 1490.0, - 1480.0, - 1470.0, - 1460.0, - 1450.0, - 1440.0, - 1430.0, - 1420.0, - 1410.0, - 1400.0, - 1390.0, - 1380.0, - 1370.0, - 1360.0, - 1350.0, - 1340.0, - 1330.0, - 1320.0, - 1310.0, - 1300.0, - 1290.0, - 1280.0, - 1270.0, - 1260.0, - 1250.0, - 1240.0, - 1230.0, - 1220.0, - 1210.0, - 1200.0, - 1190.0, - 1180.0, - 1170.0, - 1160.0, - 1150.0, - 1140.0, - 1130.0, - 1120.0, - 1110.0, - 1100.0, - 1090.0, - 1080.0, - 1070.0, - 1060.0, - 1050.0, - 1040.0, - 1030.0, - 1020.0, - 1010.0, - 1000.0, - 990.0, - 980.0, - 970.0, - 960.0, - 950.0, - 940.0, - 930.0, - 920.0, - 910.0, - 900.0, - 890.0, - 880.0, - 870.0, - 860.0, - 850.0, - 840.0, - 830.0, - 820.0, - 810.0, - 800.0, - 790.0, - 780.0, - 770.0, - 760.0, - 750.0, - 740.0, - 730.0, - 720.0, - 710.0, - 700.0, - 690.0, - 680.0, - 670.0, - 660.0, - 650.0, - 640.0, - 630.0, - 620.0, - 610.0, - 600.0, - 590.0, - 580.0, - 570.0, - 560.0, - 550.0, - 540.0, - 530.0, - 520.0, - 510.0, - 500.0, - 490.0, - 480.0, - 470.0, - 460.0, - 450.0, - 440.0, - 430.0, - 420.0, - 410.0, - 400.0, - 390.0, - 380.0, - 370.0, - 360.0, - 350.0, - 340.0, - 330.0, - 320.0, - 310.0, - 300.0, - 290.0, - 280.0, - 270.0, - 260.0, - 250.0, - 240.0, - 230.0, - 220.0, - 210.0, - 200.0, - 190.0, - 180.0, - 170.0, - 160.0, - 150.0, - 140.0, - 130.0, - 120.0, - 110.0, - 100.0, - 90.0, - 80.0, - 70.0, - 60.0, - 50.0, - 40.0, - 30.0, - 20.0, - 10.0, - 0.0 + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920, + 1930, + 1940, + 1950, + 1960, + 1970, + 1980, + 1990, + 2000, + 2010, + 2020, + 2030, + 2040, + 2050, + 2060, + 2070, + 2080, + 2090, + 2100, + 2110, + 2120, + 2130, + 2140, + 2150, + 2160, + 2170, + 2180, + 2190, + 2200, + 2210, + 2220, + 2230, + 2240, + 2250, + 2260, + 2270, + 2280, + 2290, + 2300, + 2310, + 2320, + 2330, + 2340, + 2350, + 2360, + 2370, + 2380, + 2390, + 2400, + 2410, + 2420, + 2430, + 2440, + 2450, + 2460, + 2470, + 2480, + 2490, + 2500, + 2510, + 2520, + 2530, + 2540, + 2550, + 2560, + 2570, + 2580, + 2590, + 2600, + 2610, + 2620, + 2630, + 2640, + 2650, + 2660, + 2670, + 2680, + 2690, + 2700, + 2710, + 2720, + 2730, + 2740, + 2750, + 2760, + 2770, + 2780, + 2790, + 2800, + 2810, + 2820, + 2830, + 2840, + 2850, + 2860, + 2870, + 2880, + 2890, + 2900, + 2910, + 2920, + 2930, + 2940, + 2950, + 2960, + 2970, + 2980, + 2990, + 3000, + 3010, + 3020, + 3030, + 3040, + 3050, + 3060, + 3070, + 3080, + 3090, + 3100, + 3110, + 3120, + 3130, + 3140, + 3150, + 3160, + 3170, + 3180, + 3190, + 3200, + 3210, + 3220, + 3230, + 3240, + 3250, + 3260, + 3270, + 3280, + 3290, + 3300, + 3310, + 3320, + 3330, + 3340, + 3350, + 3360, + 3370, + 3380, + 3390, + 3400, + 3410, + 3420, + 3430, + 3440, + 3450, + 3460, + 3470, + 3480, + 3490, + 3500, + 3500, + 3490, + 3480, + 3470, + 3460, + 3450, + 3440, + 3430, + 3420, + 3410, + 3400, + 3390, + 3380, + 3370, + 3360, + 3350, + 3340, + 3330, + 3320, + 3310, + 3300, + 3290, + 3280, + 3270, + 3260, + 3250, + 3240, + 3230, + 3220, + 3210, + 3200, + 3190, + 3180, + 3170, + 3160, + 3150, + 3140, + 3130, + 3120, + 3110, + 3100, + 3090, + 3080, + 3070, + 3060, + 3050, + 3040, + 3030, + 3020, + 3010, + 3000, + 2990, + 2980, + 2970, + 2960, + 2950, + 2940, + 2930, + 2920, + 2910, + 2900, + 2890, + 2880, + 2870, + 2860, + 2850, + 2840, + 2830, + 2820, + 2810, + 2800, + 2790, + 2780, + 2770, + 2760, + 2750, + 2740, + 2730, + 2720, + 2710, + 2700, + 2690, + 2680, + 2670, + 2660, + 2650, + 2640, + 2630, + 2620, + 2610, + 2600, + 2590, + 2580, + 2570, + 2560, + 2550, + 2540, + 2530, + 2520, + 2510, + 2500, + 2490, + 2480, + 2470, + 2460, + 2450, + 2440, + 2430, + 2420, + 2410, + 2400, + 2390, + 2380, + 2370, + 2360, + 2350, + 2340, + 2330, + 2320, + 2310, + 2300, + 2290, + 2280, + 2270, + 2260, + 2250, + 2240, + 2230, + 2220, + 2210, + 2200, + 2190, + 2180, + 2170, + 2160, + 2150, + 2140, + 2130, + 2120, + 2110, + 2100, + 2090, + 2080, + 2070, + 2060, + 2050, + 2040, + 2030, + 2020, + 2010, + 2000, + 1990, + 1980, + 1970, + 1960, + 1950, + 1940, + 1930, + 1920, + 1910, + 1900, + 1890, + 1880, + 1870, + 1860, + 1850, + 1840, + 1830, + 1820, + 1810, + 1800, + 1790, + 1780, + 1770, + 1760, + 1750, + 1740, + 1730, + 1720, + 1710, + 1700, + 1690, + 1680, + 1670, + 1660, + 1650, + 1640, + 1630, + 1620, + 1610, + 1600, + 1590, + 1580, + 1570, + 1560, + 1550, + 1540, + 1530, + 1520, + 1510, + 1500, + 1490, + 1480, + 1470, + 1460, + 1450, + 1440, + 1430, + 1420, + 1410, + 1400, + 1390, + 1380, + 1370, + 1360, + 1350, + 1340, + 1330, + 1320, + 1310, + 1300, + 1290, + 1280, + 1270, + 1260, + 1250, + 1240, + 1230, + 1220, + 1210, + 1200, + 1190, + 1180, + 1170, + 1160, + 1150, + 1140, + 1130, + 1120, + 1110, + 1100, + 1090, + 1080, + 1070, + 1060, + 1050, + 1040, + 1030, + 1020, + 1010, + 1000, + 990, + 980, + 970, + 960, + 950, + 940, + 930, + 920, + 910, + 900, + 890, + 880, + 870, + 860, + 850, + 840, + 830, + 820, + 810, + 800, + 790, + 780, + 770, + 760, + 750, + 740, + 730, + 720, + 710, + 700, + 690, + 680, + 670, + 660, + 650, + 640, + 630, + 620, + 610, + 600, + 590, + 580, + 570, + 560, + 550, + 540, + 530, + 520, + 510, + 500, + 490, + 480, + 470, + 460, + 450, + 440, + 430, + 420, + 410, + 400, + 390, + 380, + 370, + 360, + 350, + 340, + 330, + 320, + 310, + 300, + 290, + 280, + 270, + 260, + 250, + 240, + 230, + 220, + 210, + 200, + 190, + 180, + 170, + 160, + 150, + 140, + 130, + 120, + 110, + 100, + 90, + 80, + 70, + 60, + 50, + 40, + 30, + 20, + 10, + 0 ], "y": [ 4.0531258862154065, @@ -1744,7 +1745,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1780,7 +1781,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1804,7 +1805,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1840,7 +1841,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1867,7 +1868,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1903,7 +1904,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1918,7 +1919,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1954,7 +1955,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -2110,7 +2111,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2146,7 +2147,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -2237,7 +2238,7 @@ ], "sequential": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2273,13 +2274,13 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], "sequentialminus": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2315,7 +2316,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ] @@ -2503,7 +2504,7 @@ { "colorscale": [ [ - 0.0, + 0, "#440154" ], [ @@ -2539,7 +2540,7 @@ "#b5de2b" ], [ - 1.0, + 1, "#fde725" ] ], @@ -15963,7 +15964,7 @@ "hoverinfo": "text", "marker": { "color": [ - 0.0, + 0, 0.001968503937007874, 0.003937007874015748, 0.005905511811023622, @@ -16474,7 +16475,7 @@ ], "colorscale": [ [ - 0.0, + 0, "rgb(255,255,255)" ], [ @@ -16506,7 +16507,7 @@ "rgb(37,37,37)" ], [ - 1.0, + 1, "rgb(0,0,0)" ] ], @@ -18165,7 +18166,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18201,7 +18202,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18225,7 +18226,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18261,7 +18262,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18288,7 +18289,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18324,7 +18325,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18339,7 +18340,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18375,7 +18376,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18531,7 +18532,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18567,7 +18568,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18658,7 +18659,7 @@ ], "sequential": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18694,13 +18695,13 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], "sequentialminus": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18736,7 +18737,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ] diff --git a/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb b/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb index e7a1a308c..a106a0ddc 100644 --- a/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb +++ b/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb @@ -58,7 +58,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"matplotlib\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb b/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb index c15c9b5a1..880745578 100644 --- a/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb +++ b/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb @@ -26,8 +26,8 @@ "\n", "import pybop\n", "\n", - "go = pybop.plot.PlotlyManager().go\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "go = pybop.plot.backends.PlotlyManager().go\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/comparison_examples/optimiser_calibration.ipynb b/examples/notebooks/comparison_examples/optimiser_calibration.ipynb index 1db386e24..358593105 100644 --- a/examples/notebooks/comparison_examples/optimiser_calibration.ipynb +++ b/examples/notebooks/comparison_examples/optimiser_calibration.ipynb @@ -32,7 +32,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb b/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb index 6250b85e9..09092ba4d 100644 --- a/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb +++ b/examples/notebooks/design_optimisation/energy_based_electrode_design.ipynb @@ -35,7 +35,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/getting_started/cost_compute_methods.ipynb b/examples/notebooks/getting_started/cost_compute_methods.ipynb index 125ae9413..3ba1717c4 100644 --- a/examples/notebooks/getting_started/cost_compute_methods.ipynb +++ b/examples/notebooks/getting_started/cost_compute_methods.ipynb @@ -28,7 +28,7 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/getting_started/maximum_a_posteriori.ipynb b/examples/notebooks/getting_started/maximum_a_posteriori.ipynb index 270fb722b..fc93c39b2 100644 --- a/examples/notebooks/getting_started/maximum_a_posteriori.ipynb +++ b/examples/notebooks/getting_started/maximum_a_posteriori.ipynb @@ -51,7 +51,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/getting_started/optimising_with_adamw.ipynb b/examples/notebooks/getting_started/optimising_with_adamw.ipynb index 9248b0b8a..6d1c12b5f 100644 --- a/examples/notebooks/getting_started/optimising_with_adamw.ipynb +++ b/examples/notebooks/getting_started/optimising_with_adamw.ipynb @@ -34,7 +34,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/getting_started/setting_optimiser_options.ipynb b/examples/notebooks/getting_started/setting_optimiser_options.ipynb index a790b26b2..0ae9270db 100644 --- a/examples/notebooks/getting_started/setting_optimiser_options.ipynb +++ b/examples/notebooks/getting_started/setting_optimiser_options.ipynb @@ -32,7 +32,7 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/notebooks/getting_started/using_transformations.ipynb b/examples/notebooks/getting_started/using_transformations.ipynb index b252dc04d..d8087213c 100644 --- a/examples/notebooks/getting_started/using_transformations.ipynb +++ b/examples/notebooks/getting_started/using_transformations.ipynb @@ -29,7 +29,8 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", "\n", "np.random.seed(8) # users can remove this line" ] diff --git a/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py b/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py index 8c68af73a..b4d5de8f5 100644 --- a/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py +++ b/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py @@ -117,8 +117,18 @@ print("True values:", [original_D_n, original_D_p]) # Plot the optimisation result - result.plot_convergence(yaxis={"type": "log"}) - result.plot_parameters(yaxis={"type": "log"}, yaxis2={"type": "log"}) + pybop.plot.use_backend("plotly") + fig1 = result.plot_convergence(show=False) + fig1.update_layout( + yaxis={"type": "log"} + ) # use ax.set_yscale('log') if using matplotlib (where ax = fig1.gca()) + fig1.show() + + fig2 = result.plot_parameters(show=False) + fig2.update_layout( + yaxis={"type": "log"}, yaxis2={"type": "log"} + ) # use ax.set_yscale('log') if using matplotlib (for ax in fig2.axes) + fig2.show() # Plot the prior and posterior distributions pybop.plot.distribution(result.problem.parameters, result.posterior) diff --git a/examples/scripts/battery_parameterisation/gitt_fitting.py b/examples/scripts/battery_parameterisation/gitt_fitting.py index 3582f8e5c..6abe016f2 100644 --- a/examples/scripts/battery_parameterisation/gitt_fitting.py +++ b/examples/scripts/battery_parameterisation/gitt_fitting.py @@ -91,7 +91,7 @@ fitted_values["Voltage [V]"].data, solution["Voltage [V]"].data, ], - trace_names=["Ground truth", "Fitted GITT Model", "Identified Model"], + labels=["Ground truth", "Fitted GITT Model", "Identified Model"], xaxis_title="Time / s", yaxis_title="Voltage / V", ) diff --git a/examples/scripts/battery_parameterisation/ocp_averaging.py b/examples/scripts/battery_parameterisation/ocp_averaging.py index 9760b1672..cbf3479a5 100644 --- a/examples/scripts/battery_parameterisation/ocp_averaging.py +++ b/examples/scripts/battery_parameterisation/ocp_averaging.py @@ -110,15 +110,13 @@ charge_dataset["Voltage [V]"], average_dataset["Voltage [V]"], ] - trace_names = ["Discharge", "Charge", "Averaged"] - legend = dict(yanchor="top", y=0.99, xanchor="left", x=0.01) + labels = ["Discharge", "Charge", "Averaged"] fig = pybop.plot.trajectories( x=stos, y=volt, - trace_names=trace_names, + labels=labels, xaxis_title="Stoichiometry", yaxis_title="Voltage [V]", - legend=legend, ) dcap = [ @@ -131,8 +129,7 @@ fig = pybop.plot.trajectories( x=stos, y=dcap, - trace_names=trace_names, + labels=labels, xaxis_title="Stoichiometry", yaxis_title="Differential capacity [V-1]", - legend=legend, ) diff --git a/examples/scripts/battery_parameterisation/stoichiometry_fitting.py b/examples/scripts/battery_parameterisation/stoichiometry_fitting.py index adbdc09b1..394f0e3f5 100644 --- a/examples/scripts/battery_parameterisation/stoichiometry_fitting.py +++ b/examples/scripts/battery_parameterisation/stoichiometry_fitting.py @@ -30,7 +30,7 @@ parameter_values["Positive electrode OCP [V]"](stoichiometry), fitted_dataset["Voltage [V]"], ], - trace_names=["Ground truth", "Data vs. stoichiometry"], + labels=["Ground truth", "Data vs. stoichiometry"], xaxis_title="Stoichiometry", yaxis_title="Voltage / V", ) diff --git a/examples/scripts/comparison_examples/grouped_SPMe.py b/examples/scripts/comparison_examples/grouped_SPMe.py index de4c897ab..66092c12b 100644 --- a/examples/scripts/comparison_examples/grouped_SPMe.py +++ b/examples/scripts/comparison_examples/grouped_SPMe.py @@ -11,11 +11,9 @@ """ # Prepare figure -layout_options = dict( - xaxis_title="Time / s", - yaxis_title="Voltage / V", -) -plot_dict = pybop.plot.StandardPlot(layout_options=layout_options) +fig = plt.figure() +plt.xlabel("Time / s") +plt.ylabel("Voltage / V") # Use the Chen2020 parameters parameter_values = pybamm.ParameterValues("Chen2020") @@ -46,20 +44,19 @@ ) SPMe_model = pybamm.lithium_ion.SPMe(options=model_options) grouped_SPMe_model = pybop.lithium_ion.GroupedSPMe(options=model_options) -for model, param, line_style in zip( +for model, param, linestyle in zip( [SPMe_model, grouped_SPMe_model], [parameter_values, grouped_parameter_values], - ["solid", "dash"], + ["-", "--"], strict=False, ): solution = pybamm.Simulation( model, parameter_values=param, experiment=experiment, cache_esoh=False ).solve(initial_soc=init_soc) dataset = pybop.import_pybamm_solution(solution) - plot_dict.add_traces( - dataset["Time [s]"], dataset["Voltage [V]"], line_dash=line_style + plt.plot( + dataset["Time [s]"], dataset["Voltage [V]"], label=None, linestyle=linestyle ) -plot_dict() # Set up figure fig, ax = plt.subplots() diff --git a/papers/joss/param_plots.py b/papers/joss/param_plots.py index 298589b51..c65e2b08f 100644 --- a/papers/joss/param_plots.py +++ b/papers/joss/param_plots.py @@ -57,7 +57,7 @@ simulation_plot_dict = pybop.plot.StandardPlot( x=solution["Time [s]"].data, y=[corrupt_values, solution["Battery open-circuit voltage [V]"].data, values], - trace_names=[ + labels=[ "Voltage w. noise", "Open-circuit voltage", "Voltage", @@ -244,7 +244,7 @@ convergence_plot_dict = pybop.plot.StandardPlot( x=iteration_numbers, y=cost_log, - trace_names=[cost.name], + labels=[cost.name], trace_options={"line": {"width": 4, "dash": "dash"}}, ) convergence_traces.extend(convergence_plot_dict.traces) @@ -323,7 +323,7 @@ convergence_plot_dict = pybop.plot.StandardPlot( x=iteration_numbers, y=cost_log, - trace_names=cost.name + labels=cost.name + " " + ( cost.log_likelihood.name if isinstance(cost, pybop.LogPosterior) else "" diff --git a/pybop/plot/__init__.py b/pybop/plot/__init__.py index 8c669df92..6e97bc7e5 100644 --- a/pybop/plot/__init__.py +++ b/pybop/plot/__init__.py @@ -1,8 +1,21 @@ +# Plotting backend default +DEFAULT_BACKEND = 'matplotlib' +current_backend=DEFAULT_BACKEND + +from .util import ( + AxisData, + use_backend, + get_backend, + parse_data, + remove_brackets, + wrap_text +) + # # Import plots # -from .plotly_manager import PlotlyManager -from .standard_plots import StandardPlot, StandardSubplot, trajectories +from .standard_plots import StandardPlot, StandardSubplot +from .trajectories import trajectories from .contour import contour from .dataset import dataset from .convergence import convergence @@ -13,3 +26,6 @@ from .samples import trace, chains, posterior, summary_table from .predictive import predictive from .distribution import distribution + +# Import backend specific plotting functions +from . import backends diff --git a/pybop/plot/backends/__init__.py b/pybop/plot/backends/__init__.py new file mode 100644 index 000000000..bb7b9443d --- /dev/null +++ b/pybop/plot/backends/__init__.py @@ -0,0 +1,4 @@ +from .base import PlotBackend +from .matplotlib import MatplotlibBackend +from .plotly_manager import PlotlyManager +from .plotly import PlotlyBackend diff --git a/pybop/plot/backends/base.py b/pybop/plot/backends/base.py new file mode 100644 index 000000000..029cc3c04 --- /dev/null +++ b/pybop/plot/backends/base.py @@ -0,0 +1,308 @@ +from abc import ABC, abstractmethod + +from pybop.plot.util import AxisData + + +class PlotBackend(ABC): + """ + Abstract base class defining a plotting backend interface. + + Concrete implementations provide plotting functionality for a specific + visualization library (e.g. Plotly, Matplotlib) while exposing a common + API to the rest of the application. + + Methods in this interface are responsible for creating figures, adding + traces and annotations, generating specialised plot types, and rendering + results. + """ + + @abstractmethod + def create_figure( + self, + title: str = None, + xaxis_title: str = None, + yaxis_title: str = None, + traces: list = None, + style: dict = None, + ): + """ + Create and return a new figure. + + Parameters + ---------- + title : str, optional + Figure title. + xaxis_title : str, optional + X-axis label. + yaxis_title : str, optional + Y-axis label. + traces : list, optional + Initial traces to add to the figure. + style : dict, optional + Backend-specific styling options. + + Returns + ------- + object + Backend-specific figure object. + """ + raise NotImplementedError + + @abstractmethod + def make_subplots( + self, + axes: list[AxisData], + title=None, + xaxis_titles: list[str] | str = None, + yaxis_titles: list[str] | str = None, + style=None, + ): + """ + Create a figure containing multiple subplot axes. + + Parameters + ---------- + axes : list[AxisData] + Definitions describing subplot layout and configuration. + title : str, optional + Figure title. + xaxis_titles : str or list[str], optional + X-axis titles for each subplot. + yaxis_titles : str or list[str], optional + Y-axis titles for each subplot. + style : dict, optional + Backend-specific styling options. + + Returns + ------- + object + Backend-specific figure object. + """ + raise NotImplementedError + + @abstractmethod + def legend(self, fig, style: dict = None): + """ + Configure or display a legend for a figure. + + Parameters + ---------- + fig : object + Figure object. + style : dict, optional + Legend styling options. + """ + raise NotImplementedError + + @abstractmethod + def show_figure(self, fig): + """ + Render or display a figure. + + Parameters + ---------- + fig : object + Figure to display. + """ + raise NotImplementedError + + @abstractmethod + def plot_trace(self, traces: dict | list[dict], fig, ax=None, color_cycle=None): + """ + Add one or more traces to a figure or subplot. + + Parameters + ---------- + traces : dict or list[dict] + Trace definitions to plot. + fig : object + Target figure. + ax : object, optional + Target subplot axis. + color_cycle : iterable, optional + Sequence of colours used when plotting multiple traces. + """ + raise NotImplementedError + + @abstractmethod + def sample_color_scale(self, data, scale="viridis", d_min=None, d_max=None): + """ + Map data values onto a colour scale. + + Parameters + ---------- + data : array-like + Values to colour-map. + scale : str, optional + Colour scale name. + d_min : float, optional + Lower bound for normalisation. + d_max : float, optional + Upper bound for normalisation. + + Returns + ------- + array-like + Colours corresponding to the supplied data. + """ + raise NotImplementedError + + @abstractmethod + def colorbar(self, fig, data, colorscale="viridis", label=None): + """ + Add a colour bar representing a colour scale. + + Parameters + ---------- + fig : object + Target figure. + data : array-like + Data used for colour scaling. + colorscale : str, optional + Colour scale name. + label : str, optional + Colour bar label. + """ + raise NotImplementedError + + @abstractmethod + def contour_plot(self, x, y, z, colorscale="viridis"): + """ + Create a contour plot. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Surface values. + colorscale : str, optional + Colour scale name. + + Returns + ------- + object + Backend-specific contour trace or figure. + """ + raise NotImplementedError + + @abstractmethod + def fill(self, x, y, color=None, label=None): + """ + Create a filled region plot. + + Parameters + ---------- + x, y : array-like + Coordinates defining the filled area. + color : str, optional + Fill colour. + label : str, optional + Legend label. + """ + raise NotImplementedError + + @abstractmethod + def fill_between(self, x, y_upper, y_lower, color): + """ + Create a filled region between upper and lower bounds. + + Parameters + ---------- + x : array-like + X-axis values. + y_upper : array-like + Upper boundary values. + y_lower : array-like + Lower boundary values. + color : str + Fill colour. + """ + raise NotImplementedError + + @abstractmethod + def histogram_plot(self, x, name, style=None): + """ + Create a histogram. + + Parameters + ---------- + x : array-like + Data to bin. + name : str + Histogram label. + style : dict, optional + Histogram styling options. + """ + raise NotImplementedError + + @abstractmethod + def line(self, x=None, y=None, label=None, style=None): + """ + Create a line plot trace. + + Parameters + ---------- + x, y : array-like, optional + Coordinates of the line. + label : str, optional + Trace label. + style : dict, optional + Line styling options. + + Returns + ------- + object + Backend-specific line trace. + """ + raise NotImplementedError + + @abstractmethod + def scatter(self, x, y, colors, labels=None, colorscale="Greys"): + """ + Create a scatter plot. + + Parameters + ---------- + x, y : array-like + Point coordinates. + colors : array-like + Values or colours associated with each point. + labels : array-like, optional + Point labels. + colorscale : str, optional + Colour scale name. + """ + raise NotImplementedError + + @abstractmethod + def show_table(self, header, values, title): + """ + Display tabular data. + + Parameters + ---------- + header : list + Column headers. + values : list + Table contents. + title : str + Table title. + """ + raise NotImplementedError + + @abstractmethod + def vline(self, fig, x, style=None): + """ + Add a vertical reference line to a figure. + + Parameters + ---------- + fig : object + Target figure. + x : float + X-coordinate of the line. + style : dict, optional + Line styling options. + """ + raise NotImplementedError diff --git a/pybop/plot/backends/matplotlib.py b/pybop/plot/backends/matplotlib.py new file mode 100644 index 000000000..872ecc8e5 --- /dev/null +++ b/pybop/plot/backends/matplotlib.py @@ -0,0 +1,583 @@ +import numpy as np + +from pybop.plot.backends.base import PlotBackend +from pybop.plot.util import AxisData, wrap_text + + +class MatplotlibBackend(PlotBackend): + """ + Matplotlib implementation of the PlotBackend interface. + This backend converts backend-agnostic trace definitions into + Matplotlib figures, axes, and artists. Plot objects are represented + as dictionaries containing plotting arguments and metadata, allowing + higher-level plotting code to remain independent of the underlying + plotting library. + """ + + def __init__(self): + # Import matplotlib only when needed + import matplotlib as mpl + from matplotlib import pyplot as plt + + self.mpl = mpl + self.plt = plt + + # Backend identifier used by utility functions and text wrapping. + self.name = "matplotlib" + + # Enable automatic colour cycling across subplots when traces do not + # explicitly define a colour. + self.global_colorcycle = False + + # Matplotlib's default property cycle. + self.colorcycle = self.plt.rcParams["axes.prop_cycle"]() + + # Layout rectangle reserved for tight_layout(). This may be adjusted + # when legends are placed outside the plotting area. + self.rect = [0, 0, 1, 1] + + def _figsize(self, style): + """ + Convert pixel-based width and height values from a style dictionary + into a Matplotlib figsize (inches). + """ + return ( + np.ceil(style.get("width", 800) / 100), + np.ceil(style.get("height", 600) / 100), + ) + + def create_figure( + self, title=None, xaxis_title=None, yaxis_title=None, traces=None, style=None + ): + """ + Create a single-axis figure and optionally populate it with traces. + + Parameters + ---------- + title : str, optional + Figure title. + xaxis_title : str, optional + X-axis label. + yaxis_title : str, optional + Y-axis label. + traces : list[dict], optional + Trace definitions to plot immediately. + style : dict, optional + Figure styling options. + Currently supported options: + - width in pixels + - heith in pixels + - xaxis_range: range of the X-axis + - yaxis_range: range of the Y-axis + - bg_color: background color of the axis + + Returns + ------- + matplotlib.figure.Figure + Configured figure instance. + """ + style = style or {} + fig = self.plt.figure(figsize=self._figsize(style), dpi=100) + + if title is not None: + self.plt.suptitle(title) + if xaxis_title is not None: + self.plt.xlabel(xaxis_title) + if yaxis_title is not None: + self.plt.ylabel(yaxis_title) + + # Apply backend-supported figure styling options. + if "xaxis_range" in style: + self.plt.xlim(style.get("xaxis_range")) + if "yaxis_range" in style: + self.plt.ylim(style.get("yaxis_range")) + if "bg_color" in style: + ax = fig.gca() + ax.set_facecolor(style.get("bg_color")) + ax.set_axisbelow(True) + + if traces is not None: + for trace in traces: + self.plot_trace(trace, fig) + return fig + + def make_subplots( + self, + axes: list[AxisData], + title=None, + xaxis_titles: list[str] | str = None, + yaxis_titles: list[str] | str = None, + style=None, + ): + """ + Create a figure containing a custom subplot layout. + + The layout is defined by a collection of AxisData objects, which + specify subplot positions and spans within a grid. + + Parameters + ---------- + title : str, optional + Figure title. + xaxis_title : str, optional + X-axis label. + yaxis_title : str, optional + Y-axis label. + traces : list[dict], optional + Trace definitions to plot immediately. + style : dict, optional + Figure styling options. + Currently supported options: + - width in pixels + - heith in pixels + - bg_color: background color of the axis + + Returns + ------- + matplotlib.figure.Figure + Configured figure instance. + """ + + style = style or {} + + # Create figure + fig = self.plt.figure(figsize=self._figsize(style), dpi=100) + if title is not None: + self.plt.suptitle(title) + + # Determine the minimum grid size required to accommodate all subplot spans. + num_rows = max(ax.row + ax.row_span - 1 for ax in axes) + num_cols = max(ax.col + ax.col_span - 1 for ax in axes) + + axes_dict = {} + for ax in axes: + # Convert row/column span information into Matplotlib subplot indices. + idx_start = (ax.row - 1) * num_cols + ax.col + idx_end = (ax.row + ax.row_span - 2) * num_cols + ax.col + ax.col_span - 1 + axes_dict[(ax.row, ax.col)] = fig.add_subplot( + num_rows, num_cols, (idx_start, idx_end) + ) + + # Helper to support either a shared axis title or per-axis titles. + def _get_axis_title(titles, i): + if isinstance(titles, str): + return titles + if isinstance(titles, list) and i < len(titles): + return titles[i] + return None + + # Reduce wrapping width as the number of subplot rows increases. + width = np.floor(50 / num_rows) + for i, ax in enumerate(fig.axes): + if title := _get_axis_title(xaxis_titles, i): + ax.set_xlabel(wrap_text(title, width, self.name)) + if title := _get_axis_title(yaxis_titles, i): + ax.set_ylabel(wrap_text(title, width, self.name)) + if "bg_color" in style: + ax.set_facecolor(style.get("bg_color")) + ax.set_axisbelow(True) + + # Use a shared colour cycle across all subplot axes. + self.global_colorcycle = True + + return fig, axes_dict, num_rows, num_cols + + def legend(self, fig, style: dict = None): + """ + Create an axis-level or figure-level legend. + + Supports legends positioned outside the plotting area and updates + the layout rectangle used by tight_layout() accordingly. + + Parameters + ---------- + fig : matplotlib.figure.Figure + The figure object + style : dict, optional + Legend styling options. + Currently supported options: + - loc: str + - coords: tuple - is translated into bbox_to_anchor + - outside: tuple(side : str, offset: float) places + the legend outside the plot, where the side (left, + right, top, bottom) determines on wich side of the plot + the legend is placed and the offset determines the fraction + of the figure height or width reserved for the legend. + Overrides loc and coords. + - fig_legend: if true, one legend is created for the entire figure, otherwise the legend is created + for the current axis. + + """ + style = style or {} + lines_labels = [] + if style.get("fig_legend"): + axes = fig.axes + else: + axes = [fig.gca()] + + # Configure external legend placement and reserve layout space. + if "outside" in style.keys(): + side, offset = style.get("outside") + if side == "left": + style["loc"] = "upper left" + style["coords"] = (0.0, 1.0) + self.rect = [offset, 0, 1, 1] + elif side == "top": + style["loc"] = "lower right" + style["coords"] = (1.0, 1.0 - offset) + self.rect = [0, 0, 1, 1 - offset] + elif side == "bottom": + style["loc"] = "lower left" + style["coords"] = (0.0, 0.0) + self.rect = [0, offset, 1, 1] + else: + style["loc"] = "upper right" + style["coords"] = (1.0, 1.0) + self.rect = [0.0, 0, 1 - offset, 1] + + # Collect legend entries from all relevant axes. + labels_in_fig = False + lines_labels = [] + opts = {} + for ax in axes: + # Flatten handles and labels from multiple axes into a single legend. + handles, labels = ax.get_legend_handles_labels() + if handles: + lines_labels.append((handles, labels)) + labels_in_fig = True + + if labels_in_fig: + lines, labels = [sum(lol, []) for lol in zip(*lines_labels, strict=False)] + if style.get("horizontal"): + opts["ncols"] = len(lines) + if "coords" in style.keys(): + opts["bbox_to_anchor"] = style.get("coords") + if style.get("fig_legend"): + opts["loc"] = style.get("loc", "upper right") + fig.legend(lines, labels, **opts) + else: + opts["loc"] = style.get("loc", "best") + axes[0].legend(lines, labels, **opts) + + def show_figure(self, fig): + """ + Apply final layout adjustments and display the figure. + + Parameters + ---------- + fig : matplotlib.figure.Figure + The figure object + """ + + if isinstance(fig, list): + for f in fig: + f.tight_layout(rect=self.rect) + else: + fig.tight_layout(rect=self.rect) + self.plt.show() + + def plot_trace(self, traces: dict | list[dict], fig, ax=None): + """ + Convert one or more trace definitions into Matplotlib plotting calls. + + Parameters + ---------- + traces: dict or list[dict] + Each trace dictionary specifies a plotting method, positional + arguments, and keyword arguments compatible with a Matplotlib Axes + method. + fig : matplotlib.figure.Figure + The figure object + ax : matplotlib axis object, optional + Specity an axis for plotting. Otherwise current axis is used. + """ + + traces = traces if isinstance(traces, list) else [traces] + + # Extract plotting keyword arguments while removing backend metadata. + ax = ax or fig.gca() + for trace in traces: + if title := trace.get("xaxis_title"): + ax.set_xlabel(title) + if title := trace.get("yaxis_title"): + ax.set_ylabel(title) + + options = { + k: v + for k, v in trace.items() + if k + not in { + "plot_type", + "positional_args", + "xaxis_title", + "yaxis_title", + } + } + plot_type = trace.get("plot_type", "plot") + args = trace.get("positional_args", ()) + + # Apply the global colour cycle when plotting standard line traces. + if self.global_colorcycle and plot_type == "plot": + options.update(next(self.colorcycle)) + # Resolve the requested plotting method on the target axis. + plot_func = getattr(ax, plot_type) + + obj = plot_func(*args, **options) + + # Automatically attach a colourbar for filled contour plots. + if plot_type == "contourf": + self.plt.colorbar(obj) + + def sample_color_scale(self, data, scale="viridis", d_min=None, d_max=None): + """ + Map data values to RGBA colours using a Matplotlib colormap. + + Parameters + ---------- + data: ndarray + The data to be mapped + scale : str + Name of the colormap + d_min: float, optional + Minimum value to be mapped. Otherwise the minimum of the data is used. + d_max: float, optional + Maximum value to be mapped. Ohterwise maximum of the data is used. + """ + # Normalise values into the range expected by the colormap. + d_min = d_min or np.nanmin(data[np.isfinite(data)]) + d_max = d_max or np.nanmax(data[np.isfinite(data)]) + norm = self.mpl.colors.Normalize(vmin=d_min, vmax=d_max, clip=True) + norm_d = norm(data, clip=True) + if np.isscalar(norm_d): + norm_d = [norm_d] + + # Sample colours from the requested colormap. + cmap = self.mpl.colormaps[scale] + return cmap(norm_d) + + def colorbar(self, fig, data, colorscale="viridis", label=None): + """ + Add colourbar to figure + + Parameters + ---------- + fig: matplotlib.figure.Figure + The figure. + data: array-like + The data to be mapped + scale : str + Name of the colormap + label: str, optional + label to be displayed alongside colorbar + """ + # Create a normalisation matching the supplied data range. + f_min = np.nanmin(data[np.isfinite(data)]) + f_max = np.nanmax(data[np.isfinite(data)]) + norm = self.mpl.colors.Normalize(vmin=f_min, vmax=f_max, clip=True) + + # Create and attach a standalone colourbar. + cmap = self.mpl.colormaps[colorscale] + self.plt.colorbar( + self.mpl.cm.ScalarMappable(norm=norm, cmap=cmap), ax=fig.gca(), label=label + ) + + def contour_plot(self, x, y, z, colorscale="viridis"): + """ + Return trace definitions for a filled contour plot and contour lines. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Surface values. + colorscale : str, optional + Colour scale name. + + Returns + ------- + object + dictionary for contour plot definition and + dictionary for contour line definition + """ + contour = dict(positional_args=[x, y, z], plot_type="contourf", cmap=colorscale) + contour_lines = dict( + positional_args=[x, y, z], + colors=("k"), + linestyles="solid", + linewidths=0.2, + plot_type="contour", + ) + return [contour, contour_lines] + + def fill(self, x, y, color=None, label=None): + """ + Return a trace definition for a filled polygon. + + Parameters + ---------- + x, y : array-like + Coordinates defining the filled area. + color : str, optional + Fill colour. + label : str, optional + Ignored by the matplotlib implementation. + """ + return dict(positional_args=(x, y), plot_type="fill", color=color) + + def fill_between(self, x, y_upper, y_lower, color): + """ + Return a trace definition for a filled region between two curves. + + Parameters + ---------- + x : array-like + X-axis values. + y_upper : array-like + Upper boundary values. + y_lower : array-like + Lower boundary values. + color : str + Fill colour. + """ + return { + "positional_args": (x, y_upper, y_lower), + "plot_type": "fill_between", + "color": color, + } + + def histogram_plot(self, x, name, style: dict = None): + """ + Return a trace definition for a histogram. + + Parameters + ---------- + x : array-like + Data to bin. + name : str + Histogram label. + style : dict, optional + Currently only 'alpha' supported for opacity. + All other style arguments ignored. + """ + style = style or {} + + return { + "positional_args": [x], + "label": name, + "plot_type": "hist", + "alpha": style.get("alpha"), + } + + def line(self, x=None, y=None, label=None, style=None): + """ + Return a trace definition for a line plot. + + Parameters + ---------- + x, y : array-like, optional + Coordinates of the line. + If both x and y are provided, the shorter sequence determines the + plotted length. + label : str, optional + Trace label. + style : dict, optional + Line styling options. + + Returns + ------- + object + dictionary with positional argumetns, label and style arguments + """ + style = style or {} + if y is None: + raise ValueError("y must be provided") + + args = [y] + if x is not None: + size = min(len(x), len(y)) + args = [x[:size], y[:size]] + + return { + "positional_args": args, + "label": label, + **style, + } + + def scatter(self, x, y, colors=None, labels=None, colorscale="Greys"): + """ + Return a trace definition for a scatter plot. + + Parameters + ---------- + x, y : array-like + Point coordinates. + colors : array-like + Values or colours associated with each point. + labels : array-like, optional + Point labels. + Point labels are ignored by matplotlib implementation. + Argument retained for consistency with plotly. + colorscale : str, optional + Colour scale name. + """ + scatter = { + "positional_args": [x, y], + "plot_type": "scatter", + "cmap": colorscale, + } + if colors is not None: + scatter["c"] = colors + return scatter + + def show_table(self, header, values, title): + """ + Display tabular data in a standalone Matplotlib figure. + + Array-valued entries are converted to comma-separated strings before + rendering. + + Parameters + ---------- + header : list + Column headers. + values : list + Table contents. + title : str + Table title. + """ + for i, val in enumerate(values): + values[i] = [val[0], ", ".join(val[1].astype(str))] + + fig, ax = self.plt.subplots(figsize=(6, 2), dpi=100) + + # Remove axis decorations so only the table is displayed. + ax.axis("off") + ax.axis("tight") + ax.table( + cellText=values, + colLabels=header, + loc="center", + cellLoc="center", + colColours=["lightsteelblue", "lightsteelblue"], + ) + ax.set_title(title) + fig.tight_layout() + self.plt.show() + + def vline(self, fig, x, style=None): + """ + Add a vertical reference line to the current axis. + + Parameters + ---------- + fig: matplotlib.figure.Figure + The figure. + x: float + The position of the vertical line on the axis + style: dict, optional + matplotlib arguments for axvline method + """ + fig.gca() + style = style or {} + self.plt.axvline(x, **style) diff --git a/pybop/plot/backends/plotly.py b/pybop/plot/backends/plotly.py new file mode 100644 index 000000000..1d9890165 --- /dev/null +++ b/pybop/plot/backends/plotly.py @@ -0,0 +1,683 @@ +import numpy as np + +from pybop.plot.backends.base import PlotBackend +from pybop.plot.backends.plotly_manager import PlotlyManager +from pybop.plot.util import AxisData, wrap_text + +# Mapping from Matplotlib line styles to Plotly dash styles. +LINESTYLE_MAP = { + "solid": "solid", + "dashed": "dash", + "dotted": "dot", + "dashdot": "dashdot", +} + +# Mapping from Matplotlib-style marker definitions to their Plotly +# equivalents. +MARKER_MAP = {"o": "circle", "P": "cross", "X": "x", ".": None} + +# Translation between Matplotlib legend anchor keywords and Plotly +# anchor names. +ANCHOR_MAP = { + "lower": "bottom", + "upper": "top", + "left": "left", + "center": "center", + "right": "right", +} + + +class PlotlyBackend(PlotBackend): + """ + Plotly implementation of the PlotBackend interface. + + This backend converts backend-agnostic plot definitions into Plotly + figures and traces, providing interactive visualisations while + maintaining a common plotting API. + """ + + def __init__(self): + """ + Initialise the Plotly backend and associated Plotly manager. + """ + self.name = "plotly" + self.plotly_manager = PlotlyManager() + + def _figure_layout(self, style, figure_title): + axis_layout = dict( + title=dict(font={"size": 14}), + showexponent="last", + exponentformat="e", + tickfont=dict(size=12), + ) + return { + "title": figure_title, + "width": style.get("width"), + "height": style.get("height"), + "xaxis": axis_layout, + "yaxis": axis_layout, + "plot_bgcolor": style.get("bg_color"), + } + + def create_figure( + self, + title: str = None, + xaxis_title: str = None, + yaxis_title: str = None, + traces=None, + style: dict = None, + ): + """ + Create a Plotly figure. + + Parameters + ---------- + title : str, optional + Figure title. + xaxis_title : str, optional + X-axis label. + yaxis_title : str, optional + Y-axis label. + traces : list, optional + Plotly traces to add to the figure. + style : dict, optional + Currently supported options: + - width in pixels + - heith in pixels + - xaxis_range: range of the X-axis + - yaxis_range: range of the Y-axis + - bg_color: background color of the axis + + Returns + ------- + plotly.graph_objects.Figure + Configured Plotly figure. + """ + style = style or {} + layout_opts = self._figure_layout(style, title) + layout_opts.update( + { + "xaxis_title": xaxis_title, + "yaxis_title": yaxis_title, + "xaxis_range": style.get("xaxis_range"), + "yaxis_range": style.get("yaxis_range"), + "barmode": "overlay", + } + ) + layout = self.plotly_manager.go.Layout(layout_opts) + + fig = self.plotly_manager.go.Figure(data=traces, layout=layout) + return fig + + def _check_empty(self, specs, row, col): + """ + Validate that a subplot grid location is available. + + Parameters + ---------- + specs : list[list] + Plotly subplot specification grid. + row : int + Row index (1-based). + col : int + Column index (1-based). + + Raises + ------ + ValueError + If the requested subplot location overlaps an existing subplot. + """ + if specs[row - 1][col - 1] is None or len(specs[row - 1][col - 1]) > 0: + raise ValueError("Overlapping axes are not supported") + + def make_subplots( + self, + axes: list[AxisData], + title=None, + xaxis_titles: list[str] | str = None, + yaxis_titles: list[str] | str = None, + style=None, + ): + """ + Create a figure containing multiple subplots. + + Parameters + ---------- + axes : list[AxisData] + Definitions describing subplot positions and spans. + title : str, optional + Figure title. + xaxis_titles : str or list[str], optional + X-axis titles. + yaxis_titles : str or list[str], optional + Y-axis titles. + style : dict, optional + Figure styling options. + + Returns + ------- + tuple + ( + figure, + axes dictionary, + number of rows, + number of columns + ) + """ + style = style or {} + axes_dict = {} + + # Determine the minimum grid size required to accommodate all subplot spans. + num_rows = max(ax.row + ax.row_span - 1 for ax in axes) + num_cols = max(ax.col + ax.col_span - 1 for ax in axes) + + # Plotly subplot layouts are defined using a grid of specification + # dictionaries. Empty dictionaries denote subplot origins, while None + # marks cells occupied by a spanning subplot. + specs = [[{}] * num_cols for _ in range(num_rows)] + + # Generate subplots data from axes + for ax in axes: + # Ensure no subplot occupies the requested grid location. + self._check_empty(specs, ax.row, ax.col) + axes_dict[(ax.row, ax.col)] = ax + specs[ax.row - 1][ax.col - 1] = { + "colspan": ax.col_span, + "rowspan": ax.row_span, + } + # Check space available for full row-/col-span + # Add spec None to covered grid space + for row in range(ax.row, ax.row + ax.row_span - 1): + for col in range(ax.col, ax.col + ax.col_span - 1): + if row > ax.row or col > ax.col: + self._check_empty(specs, row, col) + specs[row - 1, col - 1] = None + + # Create figure with supbplots + make_subplots = self.plotly_manager.make_subplots + fig = make_subplots( + rows=num_rows, + cols=num_cols, + specs=specs, + horizontal_spacing=0.2, + vertical_spacing=0.15, + ) + + # Add axis title to each axis in the subplot + def _get_axis_title(titles, width, i): + if isinstance(titles, str): + title = titles + elif isinstance(titles, list) and i < len(titles): + title = titles[i] + else: + return None + + return wrap_text(title, width, self.name) + + # Reduce wrapping width as subplot rows increase to avoid overlapping + # axis titles in dense layouts. + width = np.floor(50 / num_rows) + for i, ax in enumerate(axes): + if title := _get_axis_title(xaxis_titles, width, i): + fig.update_xaxes( + title_text=title, + row=ax.row, + col=ax.col, + ) + if title := _get_axis_title(yaxis_titles, width, i): + fig.update_yaxes( + title_text=title, + row=ax.row, + col=ax.col, + ) + + fig.update_layout(self._figure_layout(style, title)) + + return fig, axes_dict, num_rows, num_cols + + def legend(self, fig, style: dict = None): + """ + Configure and display a figure legend. + + Parameters + ---------- + fig : plotly.graph_objects.Figure + Target figure. + style : dict, optional + Legend styling options including orientation, + location and anchor coordinates. + """ + style = style or {} + opts = {} + if style.get("horizontal"): + opts["orientation"] = "h" + if "loc" in style: + anchors = style.get("loc").split(" ") + if len(anchors) != 2: + raise ValueError("loc property must consist of 2 keywords") + opts["xanchor"] = ANCHOR_MAP.get(anchors[1], "auto") + opts["yanchor"] = ANCHOR_MAP.get(anchors[0], "auto") + if "coords" in style: + coords = style.get("coords") + opts["x"] = coords[0] + opts["y"] = coords[1] + + fig.update_layout(showlegend=True, legend=opts) + + def show_figure(self, fig): + """ + Display one or more Plotly figures. + + Parameters + ---------- + fig : Figure or iterable[Figure] + Figure or collection of figures to display. + """ + # Support displaying either a single figure or a collection of figures. + if hasattr(fig, "__len__") and len(fig) > 0: + for f in fig: + f.show() + else: + fig.show() + + def plot_trace(self, traces, fig, ax=None): + """ + Add one or more traces to a figure or subplot. + + Parameters + ---------- + traces : Trace or list[Trace] + Plotly trace objects to add. + fig : plotly.graph_objects.Figure + Target figure. + ax : AxisData, optional + Subplot location. If provided, traces are added + to the specified subplot. + + Returns + ------- + None + """ + for trace in np.atleast_1d(traces): + if ax is None: + fig.add_trace(trace) + else: + fig.add_trace(trace, row=ax.row, col=ax.col) + + def sample_color_scale(self, data, scale="viridis", d_min=None, d_max=None): + """ + Sample colours from a Plotly colour scale. + + Parameters + ---------- + data : array-like + Values to map onto the colour scale. + scale : str, optional + Plotly colour scale name. + d_min : float, optional + Minimum value used for normalisation. + d_max : float, optional + Maximum value used for normalisation. + + Returns + ------- + list + Colours corresponding to the supplied values. + """ + px = self.plotly_manager.px + # normalise and clip data + d_min = d_min or np.nanmin(data[np.isfinite(data)]) + d_max = d_max or np.nanmax(data[np.isfinite(data)]) + + d = (data - d_min) / (d_max - d_min) + if np.isscalar(d): + d = np.array([d]) + np.clip(np.asarray(d), 0, 1.0, out=d) + return px.colors.sample_colorscale(scale, list(d)) + + def colorbar(self, fig, data, colorscale="viridis", label=None): + """ + Add a standalone colour bar to a figure. + + Parameters + ---------- + fig : plotly.graph_objects.Figure + Target figure. + data : array-like + Values defining the colour range. + colorscale : str, optional + Plotly colour scale name. + label : str, optional + Colour bar title. + + Returns + ------- + None + """ + d_min = np.nanmin(data[np.isfinite(data)]) + d_max = np.nanmax(data[np.isfinite(data)]) + + colorbar = dict(thickness=25, outlinewidth=1) + if label is not None: + colorbar.update({"title": {"text": label, "side": "right"}}) + + # Plotly requires a trace to render a standalone colour bar, so an + # invisible scatter trace is added solely to display the scale. + fig.add_trace( + self.plotly_manager.go.Scatter( + x=[None], + y=[None], + mode="markers", + marker=dict( + colorscale=colorscale, + showscale=True, + cmin=d_min, + cmax=d_max, + colorbar=colorbar, + ), + showlegend=False, + hoverinfo="none", + ) + ) + + def contour_plot(self, x, y, z, colorscale="viridis"): + """ + Create a contour plot trace. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Contour values. + colorscale : str, optional + Plotly colour scale. + + Returns + ------- + plotly.graph_objects.Contour + Contour trace. + """ + # Use connectgaps=True to ill small gaps in the input grid to avoid breaks in contour regions. + return self.plotly_manager.go.Contour( + x=x, y=y, z=z, colorscale=colorscale, connectgaps=True + ) + + def fill(self, x, y, color=None, label=None): + """ + Create a filled polygon trace. + + Parameters + ---------- + x, y : array-like + Polygon coordinates. + color : str, optional + Fill colour. + label : str, optional + Legend label. + + Returns + ------- + plotly.graph_objects.Scatter + Filled polygon trace. + """ + opts = {} + if color is not None: + opts["fillcolor"] = color + if label is not None: + opts["name"] = label + + return self.plotly_manager.go.Scatter( + x=x, y=y, fill="toself", mode="text", showlegend=False, **opts + ) + + def fill_between(self, x, y_upper, y_lower, color): + """ + Create a filled region between two curves. + + Parameters + ---------- + x : array-like + X values. + y_upper : array-like + Upper boundary. + y_lower : array-like + Lower boundary. + color : str + Fill colour. + + Returns + ------- + plotly.graph_objects.Scatter + Filled area trace. + """ + + # Construct a closed polygon by traversing the upper curve forwards + # and the lower curve in reverse. + return self.plotly_manager.go.Scatter( + x=x + x[::-1], + y=y_upper + y_lower[::-1], + fill="toself", + line=dict(color="rgba(255,255,255,0)"), + hoverinfo="skip", + showlegend=False, + fillcolor=color, + ) + + def histogram_plot(self, x, name, style=None): + """ + Create a histogram trace. + + Parameters + ---------- + x : array-like + Data values. + name : str + Histogram label. + style : dict, optional + Histogram styling options. + + Returns + ------- + plotly.graph_objects.Histogram + Histogram trace. + """ + style = style or {} + + return self.plotly_manager.go.Histogram( + x=x, name=name, opacity=style.get("alpha") + ) + + def _get_line_options(self, style, opts): + """ + Populate Plotly line styling options. + + Parameters + ---------- + style : dict + User-supplied style options. + opts : dict + Trace options dictionary updated in-place. + + Returns + ------- + None + """ + linestyle = style.get("linestyle", "solid") + color = style.get("color") + opts["line"] = dict( + width=style.get("linewidth", 4), dash=LINESTYLE_MAP.get(linestyle, "solid") + ) + if color is not None: + opts["line"].update(color=color) + + def line(self, x=None, y=None, label=None, style=None): + """ + Create a line and/or marker trace. + + Parameters + ---------- + x : array-like, optional + X values. + y : array-like + Y values. + label : str, optional + Legend label. + style : dict, optional + Line and marker styling options. + Currently supported: + - linestyle: see LINESTYLE_MAP + - linewidth + - color + - marker: the marker symbol see MARKER_MAP + - markerfacecolor + - markeredgecolor + - markeredgewidth + - fillstyle (for marker) + + Returns + ------- + plotly.graph_objects.Scatter + Scatter trace configured as a line, marker plot, + or combined line-marker plot. + """ + + style = style or {} + linestyle = style.get("linestyle", "solid") + marker = style.get("marker", "none") + opts = {} + if linestyle.lower() == "none": + mode = "markers" + elif marker.lower() == "none": + mode = "lines" + else: + mode = "markers+lines" + + opts["mode"] = mode + if linestyle.lower() != "none": + self._get_line_options(style, opts) + + if marker.lower() != "none": + opts["marker"] = dict( + size=style.get("markersize", 8), + symbol=MARKER_MAP.get(marker), + ) + fillstyle = style.get("fillstyle", "full") + markerfacecolor = style.get("markerfacecolor") + markeredgecolor = style.get("markeredgecolor") + markeredgewidth = style.get("markeredgewidth") + + # Plotly uses "-open" marker variants to represent unfilled markers. + if fillstyle.lower() == "none": + opts["marker"].update(symbol=MARKER_MAP.get(marker) + "-open") + if markeredgecolor is not None: + opts["marker"].update(color=markeredgecolor) + + if markerfacecolor is not None: + opts["marker"].update(color=markerfacecolor) + if markeredgecolor is not None: + opts["marker"].update( + line_color=markeredgecolor, line_width=markeredgewidth or 1 + ) + elif markeredgewidth is not None: + opts["marker"].update(line_width=markeredgewidth) + + # Avoid creating empty legend entries for unnamed traces. + if label is None: + opts["showlegend"] = False + + kwargs = {"y": y, "name": label, **opts} + if x is not None: + kwargs["x"] = x + + return self.plotly_manager.go.Scatter(**kwargs) + + def scatter(self, x, y, colors, labels=None, colorscale="Greys"): + """ + Create a scatter plot trace. + + Parameters + ---------- + x, y : array-like + Point coordinates. + colors : array-like + Values used to colour markers. + labels : array-like, optional + Hover labels. + colorscale : str, optional + Plotly colour scale. + + Returns + ------- + plotly.graph_objects.Scatter + Scatter trace. + """ + + opts = dict( + mode="markers", + marker=dict( + color=colors, + colorscale=colorscale, + size=8, + showscale=False, + ), + showlegend=False, + ) + if labels is not None: + opts.update({"text": labels, "hoverinfo": "text"}) + return self.plotly_manager.go.Scatter(x=x, y=y, **opts) + + def show_table(self, header, values, title): + """ + Display tabular data as a Plotly table. + + Parameters + ---------- + header : list + Column headers. + values : list + Table contents. + title : str + Table title. + + Returns + ------- + None + """ + # Import plotly only when needed + + fig = self.plotly_manager.go.Figure( + data=[ + self.plotly_manager.go.Table( + header=dict(values=header), + cells=dict( + values=[[row[0] for row in values], [row[1] for row in values]] + ), + ) + ] + ) + + fig.update_layout(title=title) + fig.show() + + def vline(self, fig, x, style=None): + """ + Add a vertical reference line to a figure. + + Parameters + ---------- + fig : plotly.graph_objects.Figure + Target figure. + x : float + X-coordinate of the line. + style : dict, optional + Line styling options. + + Returns + ------- + None + """ + style = style or {} + opts = {} + self._get_line_options(style, opts) + fig.add_vline(x=x, **opts) diff --git a/pybop/plot/plotly_manager.py b/pybop/plot/backends/plotly_manager.py similarity index 100% rename from pybop/plot/plotly_manager.py rename to pybop/plot/backends/plotly_manager.py diff --git a/pybop/plot/contour.py b/pybop/plot/contour.py index da2d6a1a6..5ca8712e6 100644 --- a/pybop/plot/contour.py +++ b/pybop/plot/contour.py @@ -4,7 +4,7 @@ import numpy as np -from pybop.plot.plotly_manager import PlotlyManager +from pybop.plot.util import get_backend from pybop.problems.problem import Problem if TYPE_CHECKING: @@ -17,8 +17,9 @@ def contour( bounds: np.ndarray | None = None, transformed: bool = False, steps: int = 10, + title="Cost Landscape", show: bool = True, - **layout_kwargs, + backend: str = None, ): """ Plot a 2D visualisation of a cost landscape using Plotly. @@ -32,6 +33,8 @@ def contour( Either: - the cost function to be evaluated. Must accept a list of parameter values and return a cost value. - an optimiser result which provides a specific optimisation trace overlaid on the cost landscape. + title: str, optional + The title of the figure (default: "Cost Landscape") gradient : bool, optional If True, the gradient is shown (default: False). bounds : numpy.ndarray | list[list[float]], optional @@ -43,21 +46,21 @@ def contour( The number of grid points to divide the parameter space into along each dimension (default: 10). show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time [s]"` or - `xaxis={"title": "Time [s]", font={"size":14}}` + backend: str, optional + The plotting backend to be used. Returns ------- - plotly.graph_objs.Figure - The Plotly figure object containing the cost landscape plot. + fig : plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object containing the cost landscape plot. Raises ------ ValueError If the cost function does not return a valid cost when called with a parameter list. """ + backend_module = get_backend(backend) + plot_optim = False problem = call_object @@ -143,117 +146,105 @@ def transform_array_of_values(list_of_values, parameter): bounds[0] = transform_array_of_values(bounds[0], parameters[names[0]]) bounds[1] = transform_array_of_values(bounds[1], parameters[names[1]]) - # Import plotly only when needed - go = PlotlyManager().go - - # Set default layout properties - layout_options = dict( - title="Cost Landscape", - title_x=0.5, - title_y=0.905, - width=600, - height=600, - xaxis=dict(range=bounds[0], showexponent="last", exponentformat="e"), - yaxis=dict(range=bounds[1], showexponent="last", exponentformat="e"), - legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="right", x=1), - ) - layout_options["xaxis_title"] = ( - "Transformed " + names[0] if transformed else names[0] + figure_style = { + "width": 600, + "height": 600, + "xaxis_range": bounds[0], + "yaxis_range": bounds[1], + } + + fig = backend_module.create_figure( + title=title, + xaxis_title="Transformed " + names[0] if transformed else names[0], + yaxis_title="Transformed " + names[1] if transformed else names[1], + style=figure_style, ) - layout_options["yaxis_title"] = ( - "Transformed " + names[1] if transformed else names[1] - ) - layout = go.Layout(layout_options) # Create contour plot and update the layout - fig = go.Figure( - data=[go.Contour(x=x, y=y, z=costs, colorscale="Viridis", connectgaps=True)], - layout=layout, - ) + backend_module.plot_trace(backend_module.contour_plot(x=x, y=y, z=costs), fig) if plot_optim: # Plot the optimisation trace optim_trace = np.asarray([item[:2] for item in result.x_model]) optim_trace = optim_trace.reshape(-1, 2) - - fig.add_trace( - go.Scatter( - x=transform_array_of_values(optim_trace[:, 0], parameters[names[0]]), - y=transform_array_of_values(optim_trace[:, 1], parameters[names[1]]), - mode="markers", - marker=dict( - color=[i / len(optim_trace) for i in range(len(optim_trace))], - colorscale="Greys", - size=8, - showscale=False, - ), - showlegend=False, - ) + backend_module.plot_trace( + backend_module.scatter( + transform_array_of_values(optim_trace[:, 0], parameters[names[0]]), + transform_array_of_values(optim_trace[:, 1], parameters[names[1]]), + [i / optim_trace.shape[0] for i in range(optim_trace.shape[0])], + ), + fig, ) # Plot the initial guess if len(result.x_model) > 0: x0 = result.x_model[0] - fig.add_trace( - go.Scatter( + backend_module.plot_trace( + backend_module.line( x=transform_array_of_values([x0[0]], parameters[names[0]]), y=transform_array_of_values([x0[1]], parameters[names[1]]), - mode="markers", - marker_symbol="x", - marker=dict( - color="white", - line_color="black", - line_width=1, - size=14, - showscale=False, + label="Initial values", + style=dict( + marker="X", + markersize=14, + markerfacecolor="white", + markeredgecolor="black", + linestyle="None", + zorder=2.6, ), - name="Initial values", - ) + ), + fig, ) # Plot optimised value if result.x is not None: x_best = result.x - fig.add_trace( - go.Scatter( + backend_module.plot_trace( + backend_module.line( x=transform_array_of_values([x_best[0]], parameters[names[0]]), y=transform_array_of_values([x_best[1]], parameters[names[1]]), - mode="markers", - marker_symbol="cross", - marker=dict( - color="black", - line_color="white", - line_width=1, - size=14, - showscale=False, + style=dict( + marker="P", + markersize=14, + markerfacecolor="black", + markeredgecolor="white", + linestyle="None", + zorder=2.6, ), - name="Final values", - ) + label="Final values", + ), + fig, ) - # Update the layout and display the figure - fig.update_layout(**layout_kwargs) + backend_module.legend( + fig, + style={ + "horizontal": True, + "loc": "lower right", + "coords": (1, 1), + }, + ) + # display the figure if show: - fig.show() + backend_module.show_figure(fig) if gradient: grad_figs = [] for i, grad_costs in enumerate(grad_parameter_costs): - # Update title for gradient plots - updated_layout_options = layout_options.copy() - updated_layout_options["title"] = f"Gradient for Parameter: {i + 1}" - - # Create contour plot with updated layout options - grad_layout = go.Layout(updated_layout_options) - # Create fig - grad_fig = go.Figure( - data=[go.Contour(x=x, y=y, z=grad_costs)], layout=grad_layout + grad_fig = backend_module.create_figure( + title=f"Gradient for Parameter: {i + 1}", + xaxis_title="Transformed " + names[0] if transformed else names[0], + yaxis_title="Transformed " + names[1] if transformed else names[1], + style=figure_style, + ) + + backend_module.plot_trace( + backend_module.contour_plot(x=x, y=y, z=grad_costs), grad_fig ) - grad_fig.update_layout(**layout_kwargs) if show: - grad_fig.show() + backend_module.show_figure(grad_fig) # append grad_fig to list grad_figs.append(grad_fig) diff --git a/pybop/plot/convergence.py b/pybop/plot/convergence.py index deabf66d5..439bf9e5c 100644 --- a/pybop/plot/convergence.py +++ b/pybop/plot/convergence.py @@ -1,12 +1,12 @@ from typing import TYPE_CHECKING -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend if TYPE_CHECKING: from pybop._result import Result -def convergence(result: "Result", show=True, **layout_kwargs): +def convergence(result: "Result", show: bool = True, backend: str = None): """ Plot the convergence of the optimisation algorithm. @@ -16,15 +16,13 @@ def convergence(result: "Result", show=True, **layout_kwargs): Optimisation result containing the history of parameter values and associated cost. show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time [s]"` or - `xaxis={"title": "Time [s]", font={"size":14}}` + backend : str, optional + Select a plotting backend. If None, the current default backend is used. Returns --------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the convergence plot. + fig : plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the convergence plot. """ # Extract log from the optimisation object @@ -33,22 +31,28 @@ def convergence(result: "Result", show=True, **layout_kwargs): # Generate a list of iteration numbers iteration_numbers = list(range(1, len(cost_log) + 1)) - # Create a plot dictionary - plot_dict = StandardPlot( - x=iteration_numbers, - y=cost_log, - layout_options=dict( - xaxis_title="Evaluation", - yaxis_title="Cost", - title="Convergence", + backend = get_backend(backend) + + # Create figure + fig = backend.create_figure( + xaxis_title="Evaluation", + yaxis_title="Cost", + title="Convergence", + style={"bg_color": "white", "width": 600, "height": 600}, + ) + + # Add line plot + backend.plot_trace( + backend.line( + x=iteration_numbers, + y=cost_log, + label=result.method_name, ), - trace_names=result.method_name, + fig, ) - # Generate and display the figure - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) + # Display or return figure if show: - fig.show() - - return fig + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/dataset.py b/pybop/plot/dataset.py index 24257a732..2c00f4b35 100644 --- a/pybop/plot/dataset.py +++ b/pybop/plot/dataset.py @@ -1,7 +1,8 @@ -from pybop.plot.standard_plots import StandardPlot, trajectories +from pybop.plot.trajectories import trajectories +from pybop.plot.util import get_backend, remove_brackets -def dataset(dataset, signal=None, trace_names=None, show=True, **layout_kwargs): +def dataset(dataset, signal=None, labels=None, show=True, backend=None): """ Quickly plot a PyBOP Dataset using Plotly. @@ -11,19 +12,15 @@ def dataset(dataset, signal=None, trace_names=None, show=True, **layout_kwargs): A PyBOP dataset. signal : list or str, optional The name of the time series to plot (default: "Voltage [V]"). - trace_names : list or str, optional + labels : list or str, optional Name(s) for the trace(s) (default: "Data"). show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time / s"` or - `xaxis={"title": "Time [s]", font={"size":14}}` Returns ------- - plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. + fig : plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the scatter plot. """ # Get data dictionary @@ -34,25 +31,27 @@ def dataset(dataset, signal=None, trace_names=None, show=True, **layout_kwargs): # Compile ydata and labels or legend y = [dataset[s] for s in signal] if len(signal) == 1: - yaxis_title = StandardPlot.remove_brackets(signal[0]) - if trace_names is None: - trace_names = ["Data"] + yaxis_title = remove_brackets(signal[0]) + if labels is None: + labels = ["Data"] else: yaxis_title = "Output" - if trace_names is None: - trace_names = StandardPlot.remove_brackets(signal) + if labels is None: + labels = remove_brackets(signal) # Create the figure fig = trajectories( x=dataset[dataset.domain], y=y, - trace_names=trace_names, + labels=labels, show=False, - xaxis_title=StandardPlot.remove_brackets(dataset.domain), + xaxis_title=remove_brackets(dataset.domain), yaxis_title=yaxis_title, + backend=backend, ) - fig.update_layout(**layout_kwargs) + + backend_module = get_backend(backend) if show: - fig.show() + backend_module.show_figure(fig) return fig diff --git a/pybop/plot/distribution.py b/pybop/plot/distribution.py index e0e0430dc..6ac6c0529 100644 --- a/pybop/plot/distribution.py +++ b/pybop/plot/distribution.py @@ -2,6 +2,7 @@ from pybop.parameters.parameter import Parameters from pybop.plot.standard_plots import StandardSubplot +from pybop.plot.util import get_backend def distribution( @@ -10,22 +11,18 @@ def distribution( n_samples: int = 100, transformed: bool = False, show: bool = True, - **layout_kwargs, + backend: str = None, ): """ Plot the posterior on top of the prior distribution for a Bayesian optimisation result. """ # Create lists of axis titles and trace names - axis_titles = [] - trace_names = ( - parameters.names - if posterior is None - else ["Prior"] * len(parameters) + ["Posterior"] * len(parameters) - ) + xaxis_titles = [] + yaxis_titles = [] + labels = parameters.names if posterior is None else ["Prior"] * len(parameters) for name in parameters.names: - axis_titles.append( - (name + " (transformed)" if transformed else name, "Probability density") - ) + xaxis_titles.append(name + " (transformed)" if transformed else name) + yaxis_titles.append("Probability density") # Evaluate marginal distributions for each parameter values = [] @@ -37,22 +34,20 @@ def distribution( values.append(parameter_range) probability.append([d.pdf(s) for s in values[-1]]) - # Set subplot layout options - layout_options = dict( - width=1024, - height=576, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), - ) + # Get plotting backend + backend = get_backend(backend) # Create a plot dictionary plot_dict = StandardSubplot( x=values, y=probability, - axis_titles=axis_titles, - layout_options=layout_options, - trace_names=trace_names, - trace_name_width=50, + xaxis_titles=xaxis_titles, + yaxis_titles=yaxis_titles, + labels=labels, + style={"width": 1024, "height": 576}, + backend=backend, ) + fig = plot_dict(show=False) if posterior is not None: @@ -63,15 +58,21 @@ def distribution( values.append(parameter_range) probability.append([d.pdf(s) for s in values[-1]]) - trace = plot_dict.create_trace( - values[-1], probability[-1], **plot_dict.trace_options - ) + line = backend.line(values[-1], probability[-1], label="Posterior") row = (idx // plot_dict.num_cols) + 1 col = (idx % plot_dict.num_cols) + 1 - fig.add_trace(trace, row=row, col=col) - - fig.update_layout(**layout_kwargs) + backend.plot_trace(line, fig, ax=plot_dict.axes[row, col]) + backend.legend( + fig, + style=dict( + horizontal=True, + outside=("top", 0.1), + loc="lower right", + coords=(1, 1.02), + fig_legend=True, + ), + ) if show: - fig.show() - - return fig + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/nyquist.py b/pybop/plot/nyquist.py index 80f7eb77a..a4f5ed04d 100644 --- a/pybop/plot/nyquist.py +++ b/pybop/plot/nyquist.py @@ -1,8 +1,10 @@ from pybop.parameters.parameter import Inputs -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend -def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): +def nyquist( + problem, inputs: Inputs = None, show=True, title="Nyquist Plot", backend=None +): """ Generates Nyquist plots for the given problem by evaluating the model's output and target values. @@ -11,19 +13,20 @@ def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): problem : pybop.Problem An instance of a problem class that contains the parameters and methods for evaluation and target retrieval. + title: str, optional + The title of the figure inputs : Inputs, optional Input parameters for the problem. If not provided, the default parameters from the problem instance will be used. These parameters are verified before use (default is None). show : bool, optional If True, the plots will be displayed. - **layout_kwargs : dict, optional - Additional keyword arguments for customising the plot layout. These arguments are passed to - `fig.update_layout()`. + backend: str, optional + The plotting backend to be used. Returns ------- list - A list of plotly `Figure` objects, each representing a Nyquist plot for the model's output and target values. + A list of plotly or matplotlib `Figure` objects, each representing a Nyquist plot for the model's output and target values. Notes ----- @@ -31,7 +34,6 @@ def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): of the impedance from the target output. - For each signal in the problem, a Nyquist plot is created with the model's impedance plotted as a scatter plot. - An additional trace for the reference (target output) is added to the plot. - - The plot layout can be customised using `layout_kwargs`. Example ------- @@ -39,81 +41,56 @@ def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): >>> nyquist_figures = nyquist(problem, show=True, title="Nyquist Plot", xaxis_title="Real(Z)", yaxis_title="Imag(Z)") >>> # The plots will be displayed and nyquist_figures will contain the list of figure objects. """ + + trace_style_model = dict( + linewidth=2, + color="#00CC96", + marker="o", + markerfacecolor="#00CC96", + ) + trace_style_reference = dict( + linestyle="none", marker="o", fillstyle="none", markeredgecolor="#636EFA" + ) + if not isinstance(inputs, dict): inputs = problem.parameters.to_dict(inputs) model_output = problem.simulate(inputs) domain_data = model_output["Impedance"].data.real target_output = problem.target_data - figure_list = [] + backend = get_backend(backend) for var in problem.target: - default_layout_options = dict( - title="Nyquist Plot", - font=dict(family="Arial", size=14), - plot_bgcolor="white", - paper_bgcolor="white", - xaxis=dict( - title=dict(text="Zre / Ω", font=dict(size=16), standoff=15), - showline=True, - linewidth=2, - linecolor="black", - mirror=True, - ticks="outside", - tickwidth=2, - tickcolor="black", - ticklen=5, - ), - yaxis=dict( - title=dict(text="-Zim / Ω", font=dict(size=16), standoff=15), - showline=True, - linewidth=2, - linecolor="black", - mirror=True, - ticks="outside", - tickwidth=2, - tickcolor="black", - ticklen=5, - scaleanchor="x", - scaleratio=1, - ), - legend=dict( - orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 - ), - width=600, - height=600, - ) - - plot_dict = StandardPlot( - x=domain_data, - y=-model_output[var].data.imag, - layout_options=default_layout_options, - trace_names="Model", + fig = backend.create_figure( + xaxis_title=r"$Z_{re} / \Omega$", + yaxis_title=r"$-Z_{im} / \Omega$", + title=title, + style={"width": 600, "height": 600, "bg_color": "white"}, ) - plot_dict.traces[0].update( - mode="lines+markers", - line=dict(color="#00CC96", width=2), - marker=dict(size=8, color="#00CC96", symbol="circle"), + backend.plot_trace( + backend.line( + x=domain_data, + y=-model_output[var].data.imag, + label="Model", + style=trace_style_model, + ), + fig, ) - target_trace = plot_dict.create_trace( - x=target_output[var].real, - y=-target_output[var].imag, - name="Reference", - mode="markers", - marker=dict(size=8, color="#636EFA", symbol="circle-open"), - showlegend=True, + backend.plot_trace( + backend.line( + x=target_output[var].real, + y=-target_output[var].imag, + label="Reference", + style=trace_style_reference, + ), + fig, ) - plot_dict.traces.append(target_trace) - - fig = plot_dict(show=False) - - # Overwrite with user-kwargs - fig.update_layout(**layout_kwargs) - if show: - fig.show() - + backend.legend(fig) figure_list.append(fig) + if show: + backend.show_figure(fig) + return figure_list diff --git a/pybop/plot/parameters.py b/pybop/plot/parameters.py index a13d9e3fb..ca171170d 100644 --- a/pybop/plot/parameters.py +++ b/pybop/plot/parameters.py @@ -2,12 +2,13 @@ from pybop.costs.log_likelihoods import GaussianLogLikelihood from pybop.plot.standard_plots import StandardSubplot +from pybop.plot.util import get_backend if TYPE_CHECKING: from pybop._result import Result -def parameters(result: "Result", show=True, **layout_kwargs): +def parameters(result: "Result", show: bool = True, backend: str = None): """ Plot the evolution of parameters during the optimisation process using Plotly. @@ -17,15 +18,13 @@ def parameters(result: "Result", show=True, **layout_kwargs): Optimisation result containing the history of parameter values and associated cost. show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time [s]"` or - `xaxis={"title": "Time [s]", font={"size":14}}` + backend: str, optional + The plotting backend to be used Returns ------- - plotly.graph_objs.Figure - A Plotly figure object showing the parameter evolution over iterations. + plotly.graph_objs.Figure or matplotlib.figure.Figure + A figure object showing the parameter evolution over iterations. """ # Extract parameters and log from the optimisation object @@ -34,37 +33,44 @@ def parameters(result: "Result", show=True, **layout_kwargs): y = [list(item) for item in zip(*result.x_model, strict=False)] # Create lists of axis titles and trace names - axis_titles = [] - trace_names = parameters.names - for name in trace_names: - axis_titles.append(("Evaluation", name)) - + xaxis_titles = [] + yaxis_titles = [] + labels = parameters.names if isinstance(result.problem, GaussianLogLikelihood): - axis_titles.append(("Evaluation", "Sigma")) - trace_names.append("Sigma") + labels.append("Sigma") - # Set subplot layout options - layout_options = dict( - title="Parameter Convergence", - width=1024, - height=576, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), - ) + for name in labels: + xaxis_titles.append("Evaluation") + yaxis_titles.append(name) + + # import plotting backend + backend = get_backend(backend) # Create a plot dictionary plot_dict = StandardSubplot( - x=x, - y=y, - axis_titles=axis_titles, - layout_options=layout_options, - trace_names=trace_names, - trace_name_width=50, + x, + y, + title="Parameter Convergence", + xaxis_titles=xaxis_titles, + yaxis_titles=yaxis_titles, + style=dict(bg_color="white", width=1600, height=800), + labels=labels, + backend=backend, ) - # Generate the figure and update the layout fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) + + # add legend + backend.legend( + fig, + style={ + "fig_legend": True, + "outside": ("right", 0.18), + }, + ) + + # Generate the figure and update the layout if show: - fig.show() + backend.show_figure(fig) return fig diff --git a/pybop/plot/predictive.py b/pybop/plot/predictive.py index 25211d6d5..cb2848524 100644 --- a/pybop/plot/predictive.py +++ b/pybop/plot/predictive.py @@ -2,8 +2,7 @@ import numpy as np -from pybop.plot.plotly_manager import PlotlyManager -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend, remove_brackets from pybop.problems.meta_problem import MetaProblem from pybop.simulators.failed_solution import FailedSolution @@ -21,13 +20,11 @@ def predictive( pdf_label: str = "PDF", colour_scale="viridis", show: bool = True, - **layout_kwargs, + backend: str | None = None, ): """ Plot the predictive posterior of a Bayesian optimisation result. """ - # Import plotly only when needed - px = PlotlyManager().px posterior_samples = result.posterior.sample_from_distribution( n_samples=number_of_traces @@ -44,16 +41,22 @@ def predictive( else [result.problem] ) figure_list = [] + backend_module = get_backend(backend) for problem in problems: - plot_dict = StandardPlot( - x=problem.domain_data, - y=problem.target_data[problem.target[0]], - layout_options=dict( - xaxis_title=StandardPlot.remove_brackets(problem.domain), - yaxis_title=StandardPlot.remove_brackets(problem.target[0]), + fig = backend_module.create_figure( + xaxis_title=remove_brackets(problem.domain), + yaxis_title=remove_brackets(problem.target[0]), + style={"bg_color": "white", "width": 600, "height": 600}, + ) + + backend_module.plot_trace( + backend_module.line( + x=problem.domain_data, + y=problem.target_data[problem.target[0]], + label=data_legend_entry, ), - trace_names=data_legend_entry, + fig, ) # Simulate the samples and add to plot @@ -61,43 +64,33 @@ def predictive( simulations = problem.simulate_batch(inputs=inputs) for pdf, sim in zip(posterior_samples_pdf, simulations, strict=False): if not isinstance(sim, FailedSolution): - plot_dict.add_traces( - x=problem.domain_data, - y=sim[problem.target[0]].data, - line={ - "dash": "dot", - "color": px.colors.sample_colorscale( - colour_scale, - (pdf - pdf_range[0]) / (pdf_range[1] - pdf_range[0]), - )[0], - }, + colors = backend_module.sample_color_scale( + pdf, d_min=pdf_range[0], d_max=pdf_range[1] + ) + backend_module.plot_trace( + backend_module.line( + x=problem.domain_data, + y=sim[problem.target[0]].data, + style=dict(color=colors[0], linestyle="dotted"), + ), + fig, ) # Add the colourbar - plot_dict.add_traces( - x=[None], - y=[None], - mode="markers", - marker={ - "size": 0, - "color": pdf_range, - "colorscale": colour_scale, - "showscale": True, - "colorbar": {"title": {"text": "Posterior PDF", "side": "right"}}, - }, + backend_module.colorbar( + fig, pdf_range, colorscale=colour_scale, label="Posterior PDF" ) if pdf_plot is not None: - plot_dict.add_traces( - x=pdf_plot[0], - y=pdf_plot[1], - trace_names=pdf_label, + backend_module.plot_trace( + backend_module.line( + x=pdf_plot[0], + y=pdf_plot[1], + labels=pdf_label, + ) ) - - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) if show: - fig.show() + backend_module.show_figure(fig) figure_list.append(fig) diff --git a/pybop/plot/problem.py b/pybop/plot/problem.py index df1d6703d..658812cfa 100644 --- a/pybop/plot/problem.py +++ b/pybop/plot/problem.py @@ -3,7 +3,7 @@ from pybop.costs.design_cost import DesignCost from pybop.costs.error_measures import ErrorMeasure from pybop.parameters.parameter import Inputs -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend, remove_brackets from pybop.problems.meta_problem import MetaProblem from pybop.problems.problem import Problem from pybop.simulators.solution import Solution @@ -12,8 +12,9 @@ def problem( problem: Problem, inputs: Inputs = None, + title="Scatter Plot", show: bool = True, - **layout_kwargs, + backend: str = None, ): """ Produce a quick plot of the target dataset against optimised model output. @@ -27,12 +28,12 @@ def problem( Problem object with dataset and targets attributes. inputs : Inputs Optimised (or example) parameter values. + title: str, optional: + The title of the plot (default: "Scatter Plot") show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time / s"` or - `xaxis={"title": "Time [s]", font={"size":14}}` + backend: str, optional + The plotting backend to be used. Returns ------- @@ -67,65 +68,64 @@ def problem( model_domain = target_domain[: len(model_output[target].data)] # Create a plot for each output + backend_module = get_backend(backend) figure_list = [] for var in problem.target: # Create a plot dictionary - plot_dict = StandardPlot( - layout_options=dict( - title="Scatter Plot", - xaxis_title=StandardPlot.remove_brackets(domain), - yaxis_title=StandardPlot.remove_brackets(var), - ) + fig = backend_module.create_figure( + title=title, + xaxis_title=remove_brackets(domain), + yaxis_title=remove_brackets(var), + style={"bg_color": "white", "width": 600, "height": 600}, ) + traces = [] - model_trace = plot_dict.create_trace( + model_trace = backend_module.line( x=model_domain, y=model_output[var].data, - name="Optimised" if isinstance(problem.cost, DesignCost) else "Model", - mode="markers" if isinstance(problem, MetaProblem) else "lines", - showlegend=True, + label="Optimised" if isinstance(problem.cost, DesignCost) else "Model", + style={ + "linestyle": "none" if isinstance(problem, MetaProblem) else "solid", + "marker": "." if isinstance(problem, MetaProblem) else "none", + }, ) - plot_dict.traces.append(model_trace) + traces.append(model_trace) - target_trace = plot_dict.create_trace( + target_trace = backend_module.line( x=target_domain, y=target_output[var].data, - name="Reference", - mode="markers", - showlegend=True, + label="Reference", + style={"linestyle": "none", "marker": "."}, ) - plot_dict.traces.append(target_trace) + traces.append(target_trace) if isinstance(problem.cost, ErrorMeasure) and len( model_output[var].data ) == len(target_output[var].data): # Compute the standard deviation as proxy for uncertainty - plot_dict.sigma = np.std(model_output[var].data - target_output[var].data) + sigma = np.std(model_output[var].data - target_output[var].data) # Convert x and upper and lower limits into lists to create a filled trace x = target_domain.tolist() - y_upper = (model_output[var].data + plot_dict.sigma).tolist() - y_lower = (model_output[var].data - plot_dict.sigma).tolist() - - fill_trace = plot_dict.create_trace( - x=x + x[::-1], - y=y_upper + y_lower[::-1], - fill="toself", - fillcolor="rgba(255,229,204,0.8)", - line=dict(color="rgba(255,255,255,0)"), - hoverinfo="skip", - showlegend=False, + y_upper = (model_output[var].data + sigma).tolist() + y_lower = (model_output[var].data - sigma).tolist() + + fill_trace = backend_module.fill_between( + x, y_upper, y_lower, color="#FFE5CC" ) - plot_dict.traces.append(fill_trace) + traces.append(fill_trace) # Reverse the order of the traces to put the model on top - plot_dict.traces = plot_dict.traces[::-1] + traces = traces[::-1] + + for trace in traces: + backend_module.plot_trace(trace, fig) + + backend_module.legend(fig) # Generate the figure and update the layout - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) if show: - fig.show() + backend_module.show_figure(fig) figure_list.append(fig) diff --git a/pybop/plot/samples.py b/pybop/plot/samples.py index 55ee77cd0..d240bc5d5 100644 --- a/pybop/plot/samples.py +++ b/pybop/plot/samples.py @@ -1,109 +1,109 @@ from typing import TYPE_CHECKING -from pybop.plot import PlotlyManager +from pybop.plot.util import get_backend if TYPE_CHECKING: from pybop.samplers.base_pints_sampler import SamplingResult -def trace(result: "SamplingResult", **kwargs): - """ - Plot trace plots for the posterior samples. - """ - # Import plotly only when needed - go = PlotlyManager().go - - for i in range(result.n_parameters): - fig = go.Figure() - - for j, chain in enumerate(result.chains): - fig.add_trace(go.Scatter(y=chain[:, i], mode="lines", name=f"Chain {j}")) - - fig.update_layout( - title=f"Parameter {i} Trace Plot", - xaxis_title="Sample Index", - yaxis_title="Value", - ) - fig.update_layout(**kwargs) - fig.show() - - -def chains(result: "SamplingResult", **kwargs): +def chains(result: "SamplingResult", show=True, backend=None): """ Plot posterior distributions for each chain. """ - # Import plotly only when needed - go = PlotlyManager().go + # Import backend + backend = get_backend(backend) - fig = go.Figure() + fig = backend.create_figure( + title="Posterior Distribution", + xaxis_title="Value", + yaxis_title="Density", + ) for i, chain in enumerate(result.chains): for j in range(chain.shape[1]): - fig.add_trace( - go.Histogram( + backend.plot_trace( + backend.histogram_plot( x=chain[:, j], name=f"Chain {i} - Parameter {j}", - opacity=0.75, - ) + style=dict(alpha=0.75), + ), + fig, ) - fig.add_shape( - type="line", - x0=result.mean[j], - y0=0, - x1=result.mean[j], - y1=result.max[j], - name=f"Mean - Parameter {j}", - line=dict(color="Black", width=1.5, dash="dash"), + backend.vline( + fig, + result.mean[j], + style=dict(linewidth=3, linestyle="dashed", color="black"), ) - fig.update_layout( - barmode="overlay", - title="Posterior Distribution", - xaxis_title="Value", - yaxis_title="Density", - ) - fig.update_layout(**kwargs) - fig.show() + backend.legend(fig) + + if show: + backend.show_figure(fig) -def posterior(result: "SamplingResult", **kwargs): +def trace(result: "SamplingResult", show=True, backend=None): """ - Plot the summed posterior distribution across chains. + Plot trace plots for the posterior samples. """ - # Import plotly only when needed - go = PlotlyManager().go - - fig = go.Figure() + # Import plotting backend + backend = get_backend(backend) - for j in range(result.all_samples.shape[1]): - histogram = go.Histogram( - x=result.all_samples[:, j], - name=f"Parameter {j}", - opacity=0.75, - ) - fig.add_trace(histogram) - fig.add_vline( - x=result.mean[j], line_width=3, line_dash="dash", line_color="black" + figlist = [] + for i in range(result.n_parameters): + fig = backend.create_figure( + title=f"Parameter {i} Trace Plot", + xaxis_title="Sample Index", + yaxis_title="Value", ) + for j, chain in enumerate(result.chains): + backend.plot_trace(backend.line(y=chain[:, i], label=f"Chain {j}"), fig) + backend.legend(fig) + figlist.append(fig) + + if show: + backend.show_figure(figlist) + + return figlist + - fig.update_layout( - barmode="overlay", +def posterior(result: "SamplingResult", backend=None, show=True): + """ + Plot the summed posterior distribution across chains. + """ + # Import backend + backend = get_backend(backend) + fig = backend.create_figure( title="Posterior Distribution", xaxis_title="Value", yaxis_title="Density", ) - fig.update_layout(**kwargs) - fig.show() - return fig + + for j in range(result.all_samples.shape[1]): + backend.plot_trace( + backend.histogram_plot( + x=result.all_samples[:, j], + name=f"Parameter {j}", + style=dict(alpha=0.75), + ), + fig, + ) + backend.vline( + fig, + result.mean[j], + style=dict(linewidth=3, linestyle="dashed", color="black"), + ) + + backend.legend(fig) + + if show: + backend.show_figure(fig) -def summary_table(result: "SamplingResult"): +def summary_table(result: "SamplingResult", backend=None): """ Display summary statistics in a table. """ - # Import plotly only when needed - go = PlotlyManager().go summary_stats = result.get_summary_statistics() @@ -116,16 +116,9 @@ def summary_table(result: "SamplingResult"): ["95% CI Upper", summary_stats["ci_upper"]], ] - fig = go.Figure( - data=[ - go.Table( - header=dict(values=header), - cells=dict( - values=[[row[0] for row in values], [row[1] for row in values]] - ), - ) - ] + backend = get_backend(backend) + backend.show_table( + header=header, + values=values, + title="Summary Statistics", ) - - fig.update_layout(title="Summary Statistics") - fig.show() diff --git a/pybop/plot/standard_plots.py b/pybop/plot/standard_plots.py index 962f9a5f8..462257eaa 100644 --- a/pybop/plot/standard_plots.py +++ b/pybop/plot/standard_plots.py @@ -1,43 +1,12 @@ import math -import textwrap -import numpy as np - -from pybop.plot.plotly_manager import PlotlyManager - -DEFAULT_LAYOUT_OPTIONS = dict( - title=None, - title_x=0.5, - xaxis=dict( - title=dict(font={"size": 14}), - showexponent="last", - exponentformat="e", - tickfont=dict(size=12), - ), - yaxis=dict( - title=dict(font={"size": 14}), - showexponent="last", - exponentformat="e", - tickfont=dict(size=12), - ), - legend=dict(x=1, y=1, xanchor="right", yanchor="top", font_size=12), - showlegend=True, - autosize=False, - width=600, - height=600, - margin=dict(l=10, r=10, b=10, t=75, pad=4), - plot_bgcolor="white", -) -DEFAULT_SUBPLOT_OPTIONS = dict( - start_cell="bottom-left", -) -DEFAULT_TRACE_OPTIONS = dict(line=dict(width=4), mode="lines") -DEFAULT_SUBPLOT_TRACE_OPTIONS = dict(line=dict(width=2), mode="lines") +from pybop.plot.backends import PlotBackend +from pybop.plot.util import AxisData, get_backend, parse_data, wrap_text class StandardPlot: """ - A class for creating and displaying interactive Plotly figures. + A class for creating and displaying figures. Parameters ---------- @@ -45,58 +14,53 @@ class StandardPlot: X-axis data points. y : list or np.ndarray, optional Primary Y-axis data points for simulated model output. - layout : Plotly layout, optional - A layout for the figure, overrides the layout options (default: None). - layout_options : dict, optional - Settings to modify the default layout (default: DEFAULT_LAYOUT_OPTIONS). - trace_options : dict, optional + title: str, optional + The title of the figure + xaxis_title: str, optional + Sets the title/label of the x-axis + yaxis_title: str, optional + Sets the title/label of the y-axis Settings to modify the default trace type (default: DEFAULT_TRACE_OPTIONS). - trace_names : str, optional + labels : str, optional Name(s) for the primary trace(s) (default: None). - trace_name_width : int, optional - Maximum length of the trace names before text wrapping is used (default: 40). + label_width : int, optional + Maximum length of the labels before text wrapping is used (default: 40). + style: dict, optional + legend_style: dict, optional + backend: str or pybop.backends.PlotBackend, optional + Plotting backend to be used to create plot Returns ------- - plotly.graph_objs.Figure + plotly.graph_objs.Figure or matplotlib.figure.Figure The generated Plotly figure. """ def __init__( self, - x=None, - y=None, - layout=None, - layout_options=None, - trace_options=None, - trace_names=None, - trace_name_width=40, + x, + y, + title: str = None, + xaxis_title: str = None, + yaxis_title: str = None, + labels: list[str] = None, + label_width=40, + style: dict = None, + legend_style: dict = None, + backend=None, ): - self.traces = [] - self.layout = layout - self.trace_name_width = trace_name_width - - # Set default layout options and update if provided - if self.layout is None: - self.layout_options = DEFAULT_LAYOUT_OPTIONS.copy() - if layout_options: - self.layout_options.update(layout_options) - - # Set default trace options and update if provided - self.trace_options = DEFAULT_TRACE_OPTIONS.copy() - if trace_options: - self.trace_options.update(trace_options) - - # Attempt to import plotly when an instance is created - self.go = PlotlyManager().go + self.lines = [] + self.backend = backend + self.title = title + self.xaxis_title = xaxis_title + self.yaxis_title = yaxis_title + self.style = style + self.legend_style = legend_style + if not isinstance(self.backend, PlotBackend): + self.backend = get_backend(backend) - # Create layout - if self.layout is None: - self.layout = self.go.Layout(**self.layout_options) - - # Add traces if x is not None and y is not None: - self.add_traces(x, y, trace_names) + self.add_lines(x, y, labels, label_width) def __call__(self, show=True): """ @@ -107,15 +71,25 @@ def __call__(self, show=True): show : bool, optional If True, the figure is shown upon creation (default: True). """ - fig = self.go.Figure(data=self.traces, layout=self.layout) - if show: - fig.show() + fig = self.backend.create_figure( + title=self.title, + xaxis_title=self.xaxis_title, + yaxis_title=self.yaxis_title, + style=self.style, + traces=self.lines, + ) - return fig + if self.legend_style is not None: + self.backend.legend(fig, style=self.legend_style) - def add_traces(self, x, y, trace_names=None, **trace_options): + if show: + self.backend.show_figure(fig) + else: + return fig + + def add_lines(self, x, y, labels=None, labelwidth=40): """ - Add a set of traces to the plot dictionary. + Add a set of lines. Parameters ---------- @@ -123,126 +97,28 @@ def add_traces(self, x, y, trace_names=None, **trace_options): X-axis data points. y : list or np.ndarray Primary Y-axis data points for simulated model output. - trace_names : str or list[str], optional - Name(s) for the primary trace(s) (default: None). + labels : str or list[str], optional + Name(s) for the primary line(s) (default: None). + label_width : int, optional + Maximum length of the labels before text wrapping is used (default: 40). """ - options = self.trace_options.copy() - options.update(trace_options) - - # Check and wrap trace names - if trace_names is not None: - if isinstance(trace_names, str): - trace_names = [trace_names] - for i, name in enumerate(trace_names): - trace_names[i] = self.wrap_text(name, width=self.trace_name_width) - # Parse the data - x, y = self.parse_data(x, y) - - # Create a trace for each trajectory + x, y = parse_data(x, y) xi = x[0] for i in range(0, len(y)): - trace_options = options.copy() if len(x) > 1: xi = x[i] - if trace_names is not None: - trace_options["name"] = trace_names[i] - else: - trace_options["showlegend"] = False - trace = self.create_trace(xi, y[i], **trace_options) - self.traces.append(trace) - - def parse_data(self, x, y): - """ - Check the type and dimensions of the data and convert if necessary to a list - of 'things plotly can take', e.g. numpy arrays or lists of numbers. - - Parameters - ---------- - x : list or np.ndarray, optional - X-axis data points. - y : list or np.ndarray, optional - Primary Y-axis data points for simulated model output. - """ - if isinstance(x, list): - # If it's a list of numpy arrays, it's fine - # If it's a list of lists, it's fine - # If it's neither, it's a list of numbers that we need to wrap - if not isinstance(x[0], np.ndarray) and not isinstance(x[0], list): - x = [x] - elif isinstance(x, np.ndarray): - x = np.squeeze(x) - if x.ndim == 1: - x = [x] - else: - x = x.tolist() - if isinstance(y, list): - if not isinstance(y[0], np.ndarray) and not isinstance(y[0], list): - y = [y] - if isinstance(y, np.ndarray): - y = np.squeeze(y) - if y.ndim == 1: - y = [y] - else: - y = y.tolist() - if len(x) > 1 and len(x) != len(y): - raise ValueError( - "Input x should have either one data series or the same number as y." - ) - return x, y - - def create_trace(self, x, y, **trace_options): - """ - Create a trace for the Plotly figure. - - Returns - ------- - plotly.graph_objs.Scatter - A trace for a Plotly figure. - """ - return self.go.Scatter(x=x, y=y, **trace_options) - - @staticmethod - def wrap_text(text, width): - """ - Wrap text to a specified width with HTML line breaks. + label = None + if labels is not None: + label = wrap_text(labels[i], 30, backend=self.backend.name) - Parameters - ---------- - text : str - The text to wrap. - width : int - The width to wrap the text to. - - Returns - ------- - str - The wrapped text. - """ - wrapped_text = textwrap.fill(text, width=width, break_long_words=False) - return wrapped_text.replace("\n", "
") - - @staticmethod - def remove_brackets(s): - """ - Remove square brackets from a string and replace with forward slashes - as per section 7.1 of the SI Handbook - """ - # If s is an iterable (but not a string), apply the function recursively to each element - if hasattr(s, "__iter__") and not isinstance(s, str): - return type(s)(StandardPlot.remove_brackets(i) for i in s) - elif isinstance(s, str): - start = s.find("[") - end = s.find("]") - if start != -1 and end != -1: - char_in_brackets = s[start + 1 : end] - return s[:start] + " / " + char_in_brackets + s[end + 1 :] - return s + line = self.backend.line(xi, y[i], label) + self.lines.append(line) class StandardSubplot(StandardPlot): """ - A class for creating and displaying a set of interactive Plotly figures in a grid layout. + A class for creating and displaying a set of figures in a grid layout. Parameters ---------- @@ -254,59 +130,70 @@ class StandardSubplot(StandardPlot): Number of rows of subplots, can be set automatically (default: None). num_cols : int, optional Number of columns of subplots, can be set automatically (default: None). - layout : Plotly layout, optional - A layout for the figure, overrides the layout options (default: None). - layout_options : dict, optional - Settings to modify the default layout (default: DEFAULT_LAYOUT_OPTIONS). - trace_options : dict, optional - Settings to modify the default trace type (default: DEFAULT_TRACE_OPTIONS). - trace_names : str, optional + title: str, optional + Title of the Figure + xaxis_titles: str or list of str, optional + titles for the x-axes (default: None) + yaxis_titles: str or list of str, optional + titles for the x-axes (default: None) + + labels : str, optional Name(s) for the primary trace(s) (default: None). - trace_name_width : int, optional + label_width : int, optional Maximum length of the trace names before text wrapping is used (default: 40). + style: dict, optional + Options for figure layout + backend: str or pybop.plot.backends.PlotBackend Returns ------- - plotly.graph_objs.Figure - The generated Plotly figure. + plotly.graph_objs.Figure or matplotlib.figure.Figure + The generated figure. """ def __init__( self, x, y, - num_rows=None, - num_cols=None, - axis_titles=None, - layout=None, - layout_options=DEFAULT_LAYOUT_OPTIONS, - subplot_options=DEFAULT_SUBPLOT_OPTIONS, - trace_options=DEFAULT_SUBPLOT_TRACE_OPTIONS, - trace_names=None, - trace_name_width=40, + num_rows: int = None, + num_cols: int = None, + title: str = None, + xaxis_titles: list[str] | str = None, + yaxis_titles: list[str] | str = None, + labels: list[str] = None, + label_width: int = 40, + style: dict = None, + backend=None, ): super().__init__( - x, y, layout, layout_options, trace_options, trace_names, trace_name_width + x, + y, + title=title, + xaxis_title=xaxis_titles, + yaxis_title=yaxis_titles, + labels=labels, + label_width=label_width, + style=style, + backend=backend, ) - self.num_traces = len(self.traces) + + self.num_lines = len(self.lines) self.num_rows = num_rows self.num_cols = num_cols if self.num_rows is None and self.num_cols is None: # Work out the number of subplots - self.num_cols = int(math.ceil(math.sqrt(self.num_traces))) - self.num_rows = int(math.ceil(self.num_traces / self.num_cols)) + self.num_cols = int(math.ceil(math.sqrt(self.num_lines))) + self.num_rows = int(math.ceil(self.num_lines / self.num_cols)) elif self.num_rows is None: - self.num_rows = int(math.ceil(self.num_traces / self.num_cols)) + self.num_rows = int(math.ceil(self.num_lines / self.num_cols)) elif self.num_cols is None: - self.num_cols = int(math.ceil(self.num_traces / self.num_rows)) - self.axis_titles = axis_titles - self.subplot_options = subplot_options.copy() - if subplot_options is not None: - for arg, value in subplot_options.items(): - self.subplot_options[arg] = value + self.num_cols = int(math.ceil(self.num_lines / self.num_rows)) - # Attempt to import plotly when an instance is created - self.make_subplots = PlotlyManager().make_subplots + self.axes_data = [] + for idx in range(self.num_lines): + row = (idx // self.num_cols) + 1 + col = (idx % self.num_cols) + 1 + self.axes_data.append(AxisData(row, col)) def __call__(self, show): """ @@ -317,70 +204,21 @@ def __call__(self, show): show : bool, optional If True, the figure is shown upon creation (default: True). """ - fig = self.make_subplots( - rows=self.num_rows, - cols=self.num_cols, - horizontal_spacing=0.1, - vertical_spacing=0.15, - **self.subplot_options, + + fig, self.axes, self.num_rows, self.num_cols = self.backend.make_subplots( + self.axes_data, + title=self.title, + xaxis_titles=self.xaxis_title, + yaxis_titles=self.yaxis_title, + style=self.style, ) - fig.update_layout(self.layout_options) - for idx, trace in enumerate(self.traces): + for idx, line in enumerate(self.lines): row = (idx // self.num_cols) + 1 col = (idx % self.num_cols) + 1 - fig.add_trace(trace, row=row, col=col) - - if self.axis_titles and idx < len(self.axis_titles): - x_title, y_title = self.axis_titles[idx] - fig.update_xaxes(title_text=x_title, row=row, col=col) - fig.update_yaxes( - title_text=y_title, - row=row, - col=col, - showexponent="last", - exponentformat="e", - ) + self.backend.plot_trace(line, fig, self.axes[(row, col)]) if show: - fig.show() - - return fig - - -def trajectories(x, y, trace_names=None, show=True, **layout_kwargs): - """ - Quickly plot one or more trajectories using Plotly. - - Parameters - ---------- - x : list or np.ndarray - X-axis data points. - y : list or np.ndarray - Y-axis data points for each trajectory. - trace_names : list or str, optional - Name(s) for the trace(s) (default: None). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time / s"` or - `xaxis={"title": "Time [s]", font={"size":14}}` - - Returns - ------- - plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. - """ - # Create a plot dictionary - plot_dict = StandardPlot( - x=x, - y=y, - trace_names=trace_names, - ) - - # Generate the figure and update the layout - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) - if show: - fig.show() - - return fig + self.backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/trajectories.py b/pybop/plot/trajectories.py new file mode 100644 index 000000000..0c6291777 --- /dev/null +++ b/pybop/plot/trajectories.py @@ -0,0 +1,60 @@ +from pybop.plot.standard_plots import StandardPlot + + +def trajectories( + x, + y, + title: str = None, + xaxis_title: str = None, + yaxis_title: str = None, + labels=None, + label_width=20, + show=True, + backend=None, +): + """ + Quickly plot one or more trajectories using Plotly. + + Parameters + ---------- + x : list or np.ndarray + X-axis data points. + y : list or np.ndarray + Y-axis data points for each trajectory. + title: str, optional + The title of the figure + xaxis_title: str, optional + Sets the title/label of the x-axis + yaxis_title: str, optional + Sets the title/label of the y-axis + Settings to modify the default trace type (default: DEFAULT_TRACE_OPTIONS). + labels : list or str, optional + Name(s) for the trace(s) (default: None). + label_width : int, optional + Maximum length of the labels before text wrapping is used (default: 20). + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend: str, optional + The plotting backend to be used. + + Returns + ------- + plotly.graph_objs.Figure + The Plotly figure object for the scatter plot. + """ + plot_dict = StandardPlot( + x, + y, + title=title, + xaxis_title=xaxis_title, + yaxis_title=yaxis_title, + labels=labels, + label_width=label_width, + style={"height": 600, "width": 600, "bg_color": "white"}, + legend_style={}, + backend=backend, + ) + + fig = plot_dict(show=show) + + return fig diff --git a/pybop/plot/util.py b/pybop/plot/util.py new file mode 100644 index 000000000..b0059e159 --- /dev/null +++ b/pybop/plot/util.py @@ -0,0 +1,139 @@ +import textwrap +from dataclasses import dataclass + +import numpy as np + +import pybop.plot + + +def use_backend(backend): + """ + Select a plotting backend to be used for all subsequent plots. + + Parameters + ---------- + backend : str + The plotting backend to be used. + """ + err_msg = ( + f"Plotting backend {backend} is not available. The current backend has not been updated. \n" + f"The current backend is set to {pybop.plot.current_backend}" + ) + if backend.lower() in ["matplotlib", "plotly"]: + pybop.plot.current_backend = backend + + else: + raise ModuleNotFoundError(err_msg) + + +def get_backend(backend=None): + """ + Get instance of PlotBackend class for a given plotting backend + + Parameters + ---------- + backend : str, optional + The plotting backend to be used (default: pybop.plot.current_backend) + """ + if backend is None: + backend = pybop.plot.current_backend + err_msg = f"Plotting backend {backend} is not available." + if backend.lower() == "matplotlib": + return pybop.plot.backends.MatplotlibBackend() + elif backend.lower() == "plotly": + return pybop.plot.backends.PlotlyBackend() + else: + raise ModuleNotFoundError(err_msg) + + +@dataclass +class AxisData: + """ + Simple dataclass to store info needed to construct + subplots from specifying size and location of individual axes. + """ + + row: int = 1 + col: int = 1 + row_span: int = 1 + col_span: int = 1 + + +def parse_data(x, y): + """ + Check the type and dimensions of the data and convert if necessary to a list + of 'things plotly can take', e.g. numpy arrays or lists of numbers. + + Parameters + ---------- + x : list or np.ndarray, optional + X-axis data points. + y : list or np.ndarray, optional + Primary Y-axis data points for simulated model output. + """ + if isinstance(x, list): + # If it's a list of numpy arrays, it's fine + # If it's a list of lists, it's fine + # If it's neither, it's a list of numbers that we need to wrap + if not isinstance(x[0], np.ndarray) and not isinstance(x[0], list): + x = [x] + elif isinstance(x, np.ndarray): + x = np.squeeze(x) + if x.ndim == 1: + x = [x] + else: + x = x.tolist() + if isinstance(y, list): + if not isinstance(y[0], np.ndarray) and not isinstance(y[0], list): + y = [y] + if isinstance(y, np.ndarray): + y = np.squeeze(y) + if y.ndim == 1: + y = [y] + else: + y = y.tolist() + if len(x) > 1 and len(x) != len(y): + raise ValueError( + "Input x should have either one data series or the same number as y." + ) + return x, y + + +def remove_brackets(s): + """ + Remove square brackets from a string and replace with forward slashes + as per section 7.1 of the SI Handbook + """ + # If s is an iterable (but not a string), apply the function recursively to each element + if hasattr(s, "__iter__") and not isinstance(s, str): + return type(s)(remove_brackets(i) for i in s) + elif isinstance(s, str): + start = s.find("[") + end = s.find("]") + if start != -1 and end != -1: + char_in_brackets = s[start + 1 : end] + return s[:start] + " / " + char_in_brackets + s[end + 1 :] + return s + + +def wrap_text(text, width, backend="matplotlib"): + """ + Wrap text to a specified width with HTML line breaks. + + Parameters + ---------- + text : str + The text to wrap. + width : int + The width to wrap the text to. + + Returns + ------- + str + The wrapped text. + """ + wrapped_text = textwrap.fill(text, width=width, break_long_words=False) + if backend == "plotly": + return wrapped_text.replace("\n", "
") + else: + return wrapped_text diff --git a/pybop/plot/voronoi.py b/pybop/plot/voronoi.py index 29be3c096..f79bc882b 100644 --- a/pybop/plot/voronoi.py +++ b/pybop/plot/voronoi.py @@ -1,11 +1,12 @@ from typing import TYPE_CHECKING import numpy as np -from scipy.spatial import Voronoi, cKDTree +from scipy.spatial import Voronoi + +from pybop.plot.util import get_backend if TYPE_CHECKING: from pybop._result import Result -from pybop.plot.plotly_manager import PlotlyManager def _voronoi_regions(x, y, f, xlim, ylim): @@ -195,44 +196,13 @@ def interpolate_point(p, q, axis, boundary_val): return np.array([boundary_val, s]) if axis == 0 else np.array([s, boundary_val]) -def assign_nearest_value(x, y, f, xi, yi): - """ - Computes an array of values given by the score of the nearest point. - - Parameters - ---------- - x : array-like - The x coordinates of points with known scores. - y : array-like - The y coordinates of points with known scores. - f : array-like - The score function at the given x and y coordinates. - xi : array-like - The x coordinates of grid points. - yi : array-like - The y coordinates of grid points. - - Returns - ------- - A numpy array containing the scores corresponding to the grid points. - """ - # Create a KD-tree for efficient nearest neighbor search - tree = cKDTree(np.column_stack((x, y))) - - # Find the nearest point for each grid point - _, indices = tree.query(np.column_stack((xi.ravel(), yi.ravel()))) - zi = f[indices].reshape(xi.shape) - - return zi - - def surface( result: "Result", + title="Voronoi Cost Landscape", bounds=None, normalise=True, - resolution=250, show=True, - **layout_kwargs, + backend=None, ): """ Plot a 2D representation of the Voronoi diagram with color-coded regions. @@ -241,21 +211,20 @@ def surface( ----------- result : pybop.Result Optimisation result containing the history of parameter values and associated cost. + title: str, optional + The title of the plot (default: "Voronoi Cost Landscape") bounds : numpy.ndarray, optional A 2x2 array specifying the [min, max] bounds for each parameter. If None, uses `cost.parameters.get_bounds_for_plotly`. normalise : bool, optional If True, the voronoi regions are computed using the Euclidean distance between points normalised with respect to the bounds (default: True). - resolution : int, optional - Resolution of the plot. Default is 500. show : bool, optional If True, the figure is shown upon creation (default: True). - **layout_kwargs : optional - Valid Plotly layout keys and their values, - e.g. `xaxis_title="Time [s]"` or - `xaxis={"title": "Time [s]", font={"size":14}}` + backend: str, optional + The plotting backend to be used. """ + backend = get_backend(backend) points = result.x_model parameters = result.problem.parameters @@ -270,11 +239,6 @@ def surface( bounds if bounds is not None else [param.bounds for param in parameters] )[:2] - # Create a grid for plot - xi = np.linspace(xlim[0], xlim[1], resolution) - yi = np.linspace(ylim[0], ylim[1], resolution) - xi, yi = np.meshgrid(xi, yi) - if normalise: if xlim[1] <= xlim[0] or ylim[1] <= ylim[0]: raise ValueError("Lower bounds must be strictly less than upper bounds.") @@ -290,13 +254,6 @@ def surface( norm_x_optim, norm_y_optim, f, (0, 1), (0, 1) ) - # Create a normalised grid - norm_xi = np.linspace(0, 1, resolution) - norm_xi, norm_yi = np.meshgrid(norm_xi, norm_xi) - - # Assign a value to each point in the grid - zi = assign_nearest_value(norm_x, norm_y, f, norm_xi, norm_yi) - # Rescale for plotting regions = [] for norm_region in norm_regions: @@ -309,116 +266,97 @@ def surface( # Compute regions x, y, f, regions = _voronoi_regions(x_optim, y_optim, f, xlim, ylim) - # Assign a value to each point in the grid - zi = assign_nearest_value(x, y, f, xi, yi) - - # Calculate the size of each Voronoi region - region_sizes = np.array([len(region) for region in regions]) - relative_sizes = (region_sizes - region_sizes.min()) / ( - region_sizes.max() - region_sizes.min() - ) - # Construct figure - go = PlotlyManager().go - fig = go.Figure() - - # Heatmap - fig.add_trace( - go.Heatmap( - x=xi[0], - y=yi[:, 0], - z=zi, - colorscale="Viridis", - zsmooth="best", - ) + names = parameters.names + fig = backend.create_figure( + title=title, + xaxis_title=names[0], + yaxis_title=names[1], + style={ + "width": 600, + "height": 600, + "xaxis_range": xlim, + "yaxis_range": ylim, + }, ) - # Add Voronoi edges - for region, size in zip(regions, relative_sizes, strict=False): + # Add Voronoi edges and fill Voronoi regions + colors = backend.sample_color_scale(f) + for j, region in enumerate(regions): x_region = region[:, 0].tolist() + [region[0, 0]] y_region = region[:, 1].tolist() + [region[0, 1]] - fig.add_trace( - go.Scatter( - x=x_region, - y=y_region, - mode="lines", - line=dict(color="white", width=0.5 + size * 0.1), - showlegend=False, - ) + backend.plot_trace( + backend.fill(x_region, y_region, color=colors[j], label=f"f={f[j]:.2f}"), + fig, + ) + + backend.plot_trace( + backend.line(x_region, y_region, style=dict(color="white", linewidth=0.5)), + fig, ) + backend.colorbar(fig, f) + # Add original points - fig.add_trace( - go.Scatter( + backend.plot_trace( + backend.scatter( x=x_optim, y=y_optim, - mode="markers", - marker=dict( - color=[i / len(x_optim) for i in range(len(x_optim))], - colorscale="Greys", - size=8, - showscale=False, - ), - text=[f"f={val:.2f}" for val in f], - hoverinfo="text", - showlegend=False, - ) + colors=[i / len(x_optim) for i in range(len(x_optim))], + labels=[f"f={val:.2f}" for val in f], + ), + fig, ) # Plot the initial guess if len(result.x_model) > 0: x0 = result.x_model[0] - fig.add_trace( - go.Scatter( + backend.plot_trace( + backend.line( x=[x0[0]], y=[x0[1]], - mode="markers", - marker_symbol="x", - marker=dict( - color="white", - line_color="black", - line_width=1, - size=14, - showscale=False, + label="Initial values", + style=dict( + marker="X", + markersize=14, + markerfacecolor="white", + markeredgecolor="black", + linestyle="None", + zorder=2.6, ), - name="Initial values", - ) + ), + fig, ) # Plot optimised value if result.x is not None: x_best = result.x - fig.add_trace( - go.Scatter( + backend.plot_trace( + backend.line( x=[x_best[0]], y=[x_best[1]], - mode="markers", - marker_symbol="cross", - marker=dict( - color="black", - line_color="white", - line_width=1, - size=14, - showscale=False, + label="Final values", + style=dict( + marker="P", + markersize=14, + markerfacecolor="black", + markeredgecolor="white", + linestyle="None", + zorder=2.6, ), - name="Final values", - ) + ), + fig, ) - names = parameters.names - fig.update_layout( - title="Voronoi Cost Landscape", - title_x=0.5, - title_y=0.905, - xaxis_title=names[0], - yaxis_title=names[1], - width=600, - height=600, - xaxis=dict(range=xlim, showexponent="last", exponentformat="e"), - yaxis=dict(range=ylim, showexponent="last", exponentformat="e"), - legend=dict(orientation="h", yanchor="bottom", y=1, xanchor="right", x=1), + backend.legend( + fig, + style={ + "horizontal": True, + "loc": "lower right", + "coords": (1, 1), + }, ) - fig.update_layout(**layout_kwargs) + if show: - fig.show() + backend.show_figure(fig) diff --git a/tests/plotting/test_plotly_manager.py b/tests/plotting/test_plotly_manager.py index 7050c1425..d5dfb9093 100644 --- a/tests/plotting/test_plotly_manager.py +++ b/tests/plotting/test_plotly_manager.py @@ -8,7 +8,7 @@ import pytest import pybop -from pybop.plot import PlotlyManager +from pybop.plot.backends import PlotlyManager # Find the Python executable python_executable = which("python") @@ -130,6 +130,9 @@ def dataset(plotly_installed): @pytest.mark.unit def test_standard_plot(dataset, plotly_installed): + # Set plotting backend + pybop.plot.use_backend("plotly") + # Check the StandardPlot class pybop.plot.StandardPlot(dataset["Time [s]"], dataset["Voltage [V]"]) @@ -169,6 +172,9 @@ def test_standard_plot(dataset, plotly_installed): @pytest.mark.unit def test_plot_dataset(dataset, plotly_installed): + # Set plotting backend + pybop.plot.use_backend("plotly") + # Test plot of a dataset pybop.plot.dataset(dataset, signal=["Voltage [V]"]) pybop.plot.dataset(dataset, signal=["Voltage [V]", "Current [A]"]) diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 6f9c19d1d..1b57dd5c5 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -6,6 +6,7 @@ import pybop +@pytest.mark.parametrize("backend", ["plotly", "matplotlib"]) class TestPlots: """ A class to test the plot classes. @@ -13,15 +14,14 @@ class TestPlots: pytestmark = pytest.mark.unit - def test_standard_plot(self): + def test_standard_plot(self, backend): # Test standard plot - trace_names = pybop.plot.StandardPlot.remove_brackets( - ["Trace [1]", "Trace [2]"] - ) + labels = pybop.plot.remove_brackets(["Trace [1]", "Trace [2]"]) plot_dict = pybop.plot.StandardPlot( x=np.ones((2, 10)), y=np.ones((2, 10)), - trace_names=trace_names, + labels=labels, + backend=backend, ) plot_dict() @@ -69,12 +69,13 @@ def dataset(self, model): solution = pybamm.Simulation(model).solve(t_eval=t_eval, t_interp=t_eval) return pybop.import_pybamm_solution(solution) - def test_dataset_plots(self, dataset): + def test_dataset_plots(self, dataset, backend): + pybop.plot.use_backend(backend) # Test plot of Dataset objects pybop.plot.trajectories( dataset["Time [s]"], dataset["Voltage [V]"], - trace_names=["Time [s]", "Voltage [V]"], + labels=["Time [s]", "Voltage [V]"], ) pybop.plot.dataset(dataset) @@ -112,7 +113,8 @@ def design_problem(self, model, parameters, experiment): ) return pybop.Problem(simulator) - def test_problem_plots(self, fitting_problem, design_problem): + def test_problem_plots(self, fitting_problem, design_problem, backend): + pybop.plot.use_backend(backend) # Test plot of Problem objects pybop.plot.problem(fitting_problem, title="Optimised Comparison") pybop.plot.problem(design_problem) @@ -122,7 +124,8 @@ def test_problem_plots(self, fitting_problem, design_problem): fitting_problem, inputs=fitting_problem.parameters.to_dict([0.6, 0.6]) ) - def test_cost_plots(self, fitting_problem, fitting_problem_no_bounds): + def test_cost_plots(self, fitting_problem, fitting_problem_no_bounds, backend): + pybop.plot.use_backend(backend) # Test plot of Cost objects pybop.plot.contour(fitting_problem, gradient=True, steps=5) @@ -143,7 +146,8 @@ def result(self, fitting_problem): optim = pybop.XNES(fitting_problem) return optim.run() - def test_optim_plots(self, result): + def test_optim_plots(self, result, backend): + pybop.plot.use_backend(backend) bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) # Plot convergence @@ -185,7 +189,8 @@ def sampling_result(self, model, parameters, dataset): sampler = pybop.SliceStepoutMCMC(log_pdf, options=options) return sampler.run() - def test_posterior_plots(self, sampling_result): + def test_posterior_plots(self, sampling_result, backend): + pybop.plot.use_backend(backend) sampling_result.get_summary_statistics() # Plot trace @@ -208,9 +213,11 @@ def test_posterior_plots(self, sampling_result): sampling_result.problem.parameters, sampling_result.posterior ) - def test_with_ipykernel(self, dataset, fitting_problem, result): + def test_with_ipykernel(self, dataset, fitting_problem, result, backend): import ipykernel + pybop.plot.use_backend(backend) + assert version.parse(ipykernel.__version__) >= version.parse("0.6") pybop.plot.dataset(dataset, signal=["Voltage [V]"]) pybop.plot.contour(fitting_problem, gradient=True, steps=5) @@ -218,7 +225,8 @@ def test_with_ipykernel(self, dataset, fitting_problem, result): result.plot_parameters() result.plot_contour(steps=5) - def test_contour_incorrect_number_of_parameters(self, model, dataset): + def test_contour_incorrect_number_of_parameters(self, model, dataset, backend): + pybop.plot.use_backend(backend) parameter_values = model.default_parameter_values # Test with less than two paramters @@ -259,7 +267,8 @@ def test_contour_incorrect_number_of_parameters(self, model, dataset): fitting_problem = pybop.Problem(simulator, cost) pybop.plot.contour(fitting_problem) - def test_nyquist(self): + def test_nyquist(self, backend): + pybop.plot.use_backend(backend) # Define model model = pybamm.lithium_ion.SPM(options={"surface form": "differential"}) parameter_values = model.default_parameter_values