diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a9ee427c..66e72b7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Features +- [#928](https://github.com/pybop-team/PyBOP/pull/928) - Adds the option to choose `matplotlib` as the plotting library for plotting functions. Additionally, figures and axes can be created manually and passed as keyword arguments to plotting functions. An example notebook `plotting.ipynb` was added to the `getting_started` directory to demonstrate usage of the new features. - [#962](https://github.com/pybop-team/PyBOP/pull/962) - Separate the surface from the bulk temperature in the `CellTemperature` model. - [#918](https://github.com/pybop-team/PyBOP/pull/918) - Adds a plot for predictions sampled from a posterior distribution (`pybop.plot.predictive`). - [#940](https://github.com/pybop-team/PyBOP/pull/940) - Adds support for Python 3.14 (EP-BOLFI optimiser and PyProBE still restricted to Python 3.12 or below). @@ -19,6 +20,7 @@ ## Breaking Changes +- [#928](https://github.com/pybop-team/PyBOP/pull/928) - Deprecates `StandardPlot` and `StandardSubplot` in favour of new standardised backend functionality. - [#960](https://github.com/pybop-team/PyBOP/pull/960) - Remove `asv` benchmarking. - [#938](https://github.com/pybop-team/PyBOP/pull/938) - Make SALib an optional dependency and remove `sensitivity_analysis` in favour of using SALib directly. - [#942](https://github.com/pybop-team/PyBOP/pull/942) - Adds `evaluate_batch` to the costs and ensures that an `Evaluation` is returned. 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 2453d087f..f7e60681a 100644 --- a/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb +++ b/examples/notebooks/battery_parameterisation/ecm_multipulse_identification.ipynb @@ -33,7 +33,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..a17625631 100644 --- a/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb +++ b/examples/notebooks/battery_parameterisation/sensitivity_analysis_hessian.ipynb @@ -29,6 +29,8 @@ "\n", "import pybop\n", "\n", + "pybop.plot.use_backend(\"matplotlib\")\n", + "\n", "np.random.seed(8) # users can remove this line" ] }, @@ -210,708 +212,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 +1746,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1780,7 +1782,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1804,7 +1806,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1840,7 +1842,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1867,7 +1869,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1903,7 +1905,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -1918,7 +1920,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -1954,7 +1956,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -2110,7 +2112,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2146,7 +2148,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -2237,7 +2239,7 @@ ], "sequential": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2273,13 +2275,13 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], "sequentialminus": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -2315,7 +2317,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ] @@ -2503,7 +2505,7 @@ { "colorscale": [ [ - 0.0, + 0, "#440154" ], [ @@ -2539,7 +2541,7 @@ "#b5de2b" ], [ - 1.0, + 1, "#fde725" ] ], @@ -15963,7 +15965,7 @@ "hoverinfo": "text", "marker": { "color": [ - 0.0, + 0, 0.001968503937007874, 0.003937007874015748, 0.005905511811023622, @@ -16474,7 +16476,7 @@ ], "colorscale": [ [ - 0.0, + 0, "rgb(255,255,255)" ], [ @@ -16506,7 +16508,7 @@ "rgb(37,37,37)" ], [ - 1.0, + 1, "rgb(0,0,0)" ] ], @@ -18165,7 +18167,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18201,7 +18203,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18225,7 +18227,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18261,7 +18263,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18288,7 +18290,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18324,7 +18326,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18339,7 +18341,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18375,7 +18377,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18531,7 +18533,7 @@ }, "colorscale": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18567,7 +18569,7 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], @@ -18658,7 +18660,7 @@ ], "sequential": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18694,13 +18696,13 @@ "#fdca26" ], [ - 1.0, + 1, "#f0f921" ] ], "sequentialminus": [ [ - 0.0, + 0, "#0d0887" ], [ @@ -18736,7 +18738,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..2f7aeaa05 100644 --- a/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb +++ b/examples/notebooks/battery_parameterisation/sensitivity_analysis_salib.ipynb @@ -42,7 +42,7 @@ } ], "source": [ - "%pip install --upgrade openpyxl pandas -q\n", + "%pip install --upgrade openpyxl \"pandas<=2.3.3\" -q\n", "\n", "import time\n", "\n", @@ -58,7 +58,7 @@ "\n", "import pybop\n", "\n", - "pybop.plot.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "pybop.plot.use_backend(\"matplotlib\")\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..c6ff14dfd 100644 --- a/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb +++ b/examples/notebooks/comparison_examples/comparing_cost_functions.ipynb @@ -26,8 +26,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/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 9cba0036a..72e9c1d86 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..972578dd2 100644 --- a/examples/notebooks/getting_started/cost_compute_methods.ipynb +++ b/examples/notebooks/getting_started/cost_compute_methods.ipynb @@ -28,7 +28,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/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/plotting.ipynb b/examples/notebooks/getting_started/plotting.ipynb new file mode 100644 index 000000000..ff46991b6 --- /dev/null +++ b/examples/notebooks/getting_started/plotting.ipynb @@ -0,0 +1,356 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# Generating and customising plots\n", + "\n", + "This example illustrates how to use the plotting functions with 'matplotlib' and 'plotly' as backends\n", + "and how to customise the plots.\n", + "\n", + "We use Monte Carlo sampling to generate a result for plotting.\n", + "\n", + "### Setting up the environment\n", + "\n", + "If you don't already have PyBOP installed, check out the [installation guide](https://pybop-docs.readthedocs.io/en/latest/installation.html) first.\n", + "\n", + "We begin by importing the necessary libraries. Let's also fix the random seed to generate consistent output during development." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install --upgrade SciencePlots -q\n", + "\n", + "import numpy as np\n", + "import pybamm\n", + "\n", + "import pybop\n", + "\n", + "np.random.seed(8) # users can remove this line" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Create a model, dataset and optimisation problem\n", + "\n", + "First set the model and parameter values." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "model = pybamm.lithium_ion.SPM()\n", + "parameter_values = pybamm.ParameterValues(\"Chen2020\")\n", + "parameter_values.set_initial_state(0.5);" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "Generate a synthetic dataset and define the fitting parameters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "sigma = 0.005\n", + "experiment = pybamm.Experiment([\"Discharge at 0.5C for 3 minutes (5 second period)\"])\n", + "solution = pybamm.Simulation(\n", + " model, parameter_values=parameter_values, experiment=experiment\n", + ").solve()\n", + "dataset = pybop.Dataset(\n", + " {\n", + " \"Time [s]\": solution.t,\n", + " \"Current [A]\": solution[\"Current [A]\"].data,\n", + " \"Voltage [V]\": pybop.add_noise(solution[\"Voltage [V]\"].data, sigma),\n", + " }\n", + ")\n", + "\n", + "parameter_values.update(\n", + " {\n", + " \"Negative electrode active material volume fraction\": pybop.Parameter(\n", + " distribution=pybop.Gaussian(0.68, 0.02)\n", + " ),\n", + " \"Positive electrode active material volume fraction\": pybop.Parameter(\n", + " distribution=pybop.Gaussian(0.65, 0.02)\n", + " ),\n", + " }\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "Build the problem and run the sampler to generate a result." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "simulator = pybop.pybamm.Simulator(\n", + " model, parameter_values=parameter_values, protocol=dataset\n", + ")\n", + "cost = pybop.GaussianLogLikelihood(dataset)\n", + "log_pdf = pybop.LogPosterior(simulator, cost)\n", + "\n", + "options = pybop.PintsSamplerOptions(\n", + " n_chains=3,\n", + " max_iterations=250, # Extend this for accurate posteriors\n", + " warm_up_iterations=100,\n", + " verbose=True,\n", + ")\n", + "sampler = pybop.DifferentialEvolutionMCMC(log_pdf, options=options)\n", + "result = sampler.run()\n", + "result.get_summary_statistics();" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## Choosing the plotting library\n", + "\n", + "By default, plots are generated using matplotlib. We can use `pybop.plot.use_backend` to change the plotting library used by the plotting functions. Valid options are `'matplotlib'` and `'plotly'`.\n", + "\n", + "Additionally, each plotting function takes an optional argument `backend` that overrides the current backend. This can either be a string (`'matplotlib'` or `'plotly'`) or an instance of `pybop.plot.backends.PlotBackend`. We will later see why the latter can be useful in very specific cases." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "result.plot_chains() # matplotlib is default, may need to restart notebook to return to default\n", + "\n", + "pybop.plot.use_backend(\"plotly\")\n", + "pybop.plot.backends.PlotlyManager().pio.renderers.default = \"notebook_connected\"\n", + "\n", + "result.plot_posterior() # now using plotly\n", + "result.plot_posterior(backend=\"matplotlib\") # but still matplotlib for this plot\n", + "result.summary_table(); # and plotly for this one" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "## Combining multiple plots into one figure\n", + "\n", + "_NOTE_:\n", + "When combining multiple plots into one figure, it is important to use the option `show = False` for all except the final plot. When using matplotlib, the plots will not work as expected without this. When using plotly, the plotting functions would still work as expected, except that unfinished versions of the figure would also be displayed.\n", + "\n", + "### Subplots with plotly\n", + "\n", + "First we generate empty subplots with plotly. We will later add a table to the plot, so we set the plot type as 'table' for the corresponding axis." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "from plotly.subplots import make_subplots\n", + "\n", + "specs = [[{}] for _ in range(5)]\n", + "specs[4][0] = {\"type\": \"table\"}\n", + "fig = make_subplots(5, 1, specs=specs, horizontal_spacing=0.2, vertical_spacing=0.05)" + ] + }, + { + "cell_type": "markdown", + "id": "12", + "metadata": {}, + "source": [ + "We can now select the axes for each plot by passing the position of the axis as a tuple `(row, col)` to the plotting function. When using `matplotlib`, we would pass an Axis oject instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "result.plot_trace(figures=fig, axes=(1, 1), show=False)\n", + "result.plot_posterior(figures=fig, axes=(2, 1), show=False)\n", + "result.plot_chains(figures=fig, axes=(3, 1), show=False)\n", + "result.plot_predictive(figures=fig, axes=(4, 1), show=False);" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, + "source": [ + "_NOTE_: There seems to be a bug in plotly ([Issue #3424](https://github.com/plotly/plotly.py/issues/3424)).\n", + "If any plot with a vertical line is added to a figure with subplots AFTER a table was added, \n", + "this may cause an error (vertical lines are added by plot_posterior and plot_chains, for example.)\n", + "This can be avoided by always adding any tables last." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "result.summary_table(figures=fig, axes=(5, 1), show=False)\n", + "fig.update_layout(height=1200)" + ] + }, + { + "cell_type": "markdown", + "id": "16", + "metadata": {}, + "source": [ + "## Customising a plot with matplotlib\n", + "To customise a plot with matplotlib after calling a `pybop.plot` plotting function, it is important to pass the argument `show=False` to avoid calling `plt.show()` prematurely. If `show=False` any plotting function will either return a single figure or a list of figures that can then be edited using standard `matplotlib` functionality. An even easier way to gain full control over the figure layout is to manually create the figure and pass it to the plotting function via the `figures` keyword argument.\n", + "\n", + "By default, matplotlib cycles through the default colours separately for each axis in a figure. To use the same colorcycle across all axis in a figure, we can set the `global_colorcycle` property of the `pybop.plot.backends.MatplotlibBackend()` and pass the backend as an argument to all plotting functions that we want to share the same colorcycle." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "17", + "metadata": {}, + "outputs": [], + "source": [ + "backend = pybop.plot.get_backend(\"matplotlib\")\n", + "backend.global_colorcycle = True" + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "We then generate a figure with matplotlib and pass the figure, its axes and the backend to the plotting function.\n", + "\n", + "By default, `plot_trace` will create a legend for each axis. Now, if instead we want a legend for all axes, we can customise the output using standard `matplotlib` functionality. Since we used the option `show=False` when creating the plot, `plt.show()` has not been called yet and the figure is still available for updating." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "from matplotlib import pyplot as plt\n", + "\n", + "fig, axes = plt.subplots(3, 1, figsize=(10, 8))\n", + "result.plot_trace(figures=fig, axes=axes, backend=backend, show=False)\n", + "\n", + "handles = []\n", + "labels = []\n", + "for ax in axes:\n", + " # remove individual legends\n", + " ax.get_legend().remove()\n", + "\n", + " # get handles and labels of current axis\n", + " hdls, lbls = ax.get_legend_handles_labels()\n", + "\n", + " # get the title of the current axis\n", + " title = ax.get_title()\n", + "\n", + " # update labels to contain title of axis\n", + " lbls = [\n", + " pybop.plot.wrap_text(title + \" - \" + label, 20, \"matplotlib\") for label in lbls\n", + " ]\n", + "\n", + " # add handles and updated labels to the global handles and labels list\n", + " handles.extend(hdls)\n", + " labels.extend(lbls)\n", + "\n", + "# add a global legend\n", + "fig.legend(handles, labels)\n", + "\n", + "# create some space for the legend\n", + "plt.tight_layout(rect=[0, 0, 0.8, 1])\n", + "\n", + "# show the updated figure\n", + "plt.show();" + ] + }, + { + "cell_type": "markdown", + "id": "20", + "metadata": {}, + "source": [ + "## Using the SciencePlots package\n", + "\n", + "When using the `matplotlib` backend we can easily use the SciencePlots package in combination with the plotting functions in PyBOP. Sometimes it might be necessary to manually generate the figure and pass it to the plotting function to properly apply the SciencePlots styling if a plotting function overrides any default options when generating the figure. This is not necessary in the following example." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21", + "metadata": {}, + "outputs": [], + "source": [ + "import scienceplots # noqa: F401\n", + "\n", + "pybop.plot.use_backend(\"matplotlib\")\n", + "\n", + "with plt.style.context([\"science\", \"no-latex\", \"ieee\"]):\n", + " result.plot_trace()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env-py-3-13 (3.13.11)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/notebooks/getting_started/setting_optimiser_options.ipynb b/examples/notebooks/getting_started/setting_optimiser_options.ipynb index a790b26b2..e4c5fa402 100644 --- a/examples/notebooks/getting_started/setting_optimiser_options.ipynb +++ b/examples/notebooks/getting_started/setting_optimiser_options.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/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 aadad5239..cf208da57 100644 --- a/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py +++ b/examples/scripts/battery_parameterisation/bayesian_feature_fitting.py @@ -107,8 +107,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 d8bf2f4e2..b5935c91e 100644 --- a/examples/scripts/battery_parameterisation/ocp_averaging.py +++ b/examples/scripts/battery_parameterisation/ocp_averaging.py @@ -103,15 +103,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 = [ @@ -124,8 +122,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..426e6cb82 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,23 @@ ) 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=model.name, + linestyle=linestyle, ) -plot_dict() +plt.legend() # Set up figure fig, ax = plt.subplots() diff --git a/noxfile.py b/noxfile.py index 97333238b..003bf1e6e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -76,7 +76,7 @@ def examples(session): @nox.session def notebooks(session): """Run the Jupyter notebooks.""" - session.install("openpyxl", "ipywidgets") + session.install("openpyxl", "ipywidgets", "SciencePlots") session.install("-e", ".[all]", "--upgrade", silent=False) session.install(*nox.project.dependency_groups(PYPROJECT, "dev"), silent=False) if PYBOP_SCHEDULED: @@ -93,7 +93,7 @@ def notebooks(session): @nox.session(name="notebooks-overwrite") def notebooks_overwrite(session): """Run the Jupyter notebooks.""" - session.install("openpyxl", "ipywidgets") + session.install("openpyxl", "ipywidgets", "SciencePlots") session.install("-e", ".[all]", "--upgrade", silent=False) session.install(*nox.project.dependency_groups(PYPROJECT, "dev"), silent=False) if PYBOP_SCHEDULED: @@ -111,7 +111,7 @@ def notebooks_overwrite(session): @nox.session(name="tests") def run_tests(session): """Run all or a user-defined set of tests.""" - session.install("openpyxl", "ipywidgets") + session.install("openpyxl", "ipywidgets", "SciencePlots") session.install("-e", ".[all]", "--upgrade", silent=False) session.install(*nox.project.dependency_groups(PYPROJECT, "dev"), silent=False) if PYBOP_SCHEDULED: diff --git a/papers/joss/design_plots.py b/papers/joss/design_plots.py index c7aec3231..50a958518 100644 --- a/papers/joss/design_plots.py +++ b/papers/joss/design_plots.py @@ -6,9 +6,10 @@ from pybamm import Parameter import pybop -from pybop.plot import PlotlyManager +from pybop.plot.backends import PlotlyManager go = PlotlyManager().go +pybop.plot.use_backend("plotly") np.random.seed(8) axis_font_size = 24 tick_font_size = 20 @@ -50,14 +51,14 @@ { "Positive electrode thickness [m]": pybop.Parameter( initial_value=8.88e-05, - prior=pybop.Gaussian(7.56e-05, 3e-05), - bounds=[50e-06, 120e-06], + distribution=pybop.Gaussian( + 7.56e-05, 3e-05, truncated_at=[50e-06, 120e-06] + ), transformation=pybop.UnitHyperCube(lower=50e-6, upper=120e-6), ), "Positive electrode active material volume fraction": pybop.Parameter( initial_value=0.42, - prior=pybop.Gaussian(0.58, 0.1), - bounds=[0.3, 0.825], + distribution=pybop.Gaussian(0.58, 0.1, truncated_at=[0.3, 0.825]), transformation=pybop.UnitHyperCube(lower=0.3, upper=0.825), ), } @@ -88,13 +89,13 @@ print("Estimated parameters:", result.x) print(f"Initial gravimetric energy density: {problem(result.x0):.1f} W.h.kg-1") print(f"Optimised gravimetric energy density: {problem(result.x):.1f} W.h.kg-1") +initial_energy_density = problem(result.x0) +optimised_energy_density = problem(result.x) if create_plot["gravimetric"]: # Plot the cost landscape with optimisation path - gravimetric_fig = pybop.plot.contour( - result, - steps=25, - show=False, + gravimetric_fig = pybop.plot.contour(result, steps=25, show=False) + gravimetric_fig.update_layout( xaxis=dict( title=dict( text="Positive electrode thickness / m", font_size=axis_font_size @@ -117,13 +118,17 @@ ) gravimetric_fig.write_image("figures/individual/design_gravimetric.pdf") + if create_plot["prediction"]: # Plot the timeseries output - problem.target = "Voltage [V]" - figs = pybop.plot.problem( + problem.set_target("Voltage [V]") + prediction_fig = pybop.plot.problem( problem, inputs=result.best_inputs, title=None, + show=False, + ) + prediction_fig.update_layout( legend=dict( orientation="h", yanchor="bottom", @@ -149,16 +154,16 @@ tickfont_size=tick_font_size, ), margin=dict(t=60, b=84, r=50, l=15), - show=False, ) - prediction_fig = figs[0] prediction_fig.data[1].update(line=dict(color="#00CC97")) prediction_fig.data[ 0 - ].name = f"Initial: {problem(result.x0):.1f} W h kg-1" + ].name = f"Initial: {initial_energy_density:.1f} W h kg-1" prediction_fig.data[ 1 - ].name = f"Optimised: {problem(result.x):.1f} W h kg-1" + ].name = ( + f"Optimised: {optimised_energy_density:.1f} W h kg-1" + ) prediction_fig.show() prediction_fig.write_image("figures/individual/design_prediction.pdf") diff --git a/papers/joss/param_plots.py b/papers/joss/param_plots.py index 298589b51..975217908 100644 --- a/papers/joss/param_plots.py +++ b/papers/joss/param_plots.py @@ -8,15 +8,16 @@ from matplotlib.ticker import ScalarFormatter import pybop -from pybop.plot import PlotlyManager +from pybop.plot.backends import PlotlyManager +pybop.plot.use_backend("plotly") go = PlotlyManager().go px = PlotlyManager().px make_subplots = PlotlyManager().make_subplots plt.rcParams.update({"text.usetex": True}) # Enable LaTeX np.random.seed(8) # Set random seed for reproducibility -axis_font_size = 24 -tick_font_size = 16 +axis_font_size = 22 +tick_font_size = 14 # Choose which plots to show and save create_plot = {} @@ -54,18 +55,28 @@ if create_plot["simulation"]: # Plot the data and the simulation - simulation_plot_dict = pybop.plot.StandardPlot( - x=solution["Time [s]"].data, - y=[corrupt_values, solution["Battery open-circuit voltage [V]"].data, values], - trace_names=[ - "Voltage w. noise", - "Open-circuit voltage", - "Voltage", - ], - ) - simulation_plot_dict.traces[0].mode = "markers" - simulation_fig = simulation_plot_dict(show=False) + simulation_plot_lines = [] + y = [corrupt_values, solution["Battery open-circuit voltage [V]"].data, values] + labels = [ + "Voltage w. noise", + "Open-circuit voltage", + "Voltage", + ] + x = (solution["Time [s]"].data,) + for i, yi in enumerate(y): + simulation_plot_lines.append( + go.Scatter( + x=x[0], + y=yi, + mode="lines", + name=labels[i], + line=dict(width=4, dash="solid"), + ) + ) + simulation_plot_lines[0].mode = "markers" + simulation_fig = go.Figure(data=simulation_plot_lines) simulation_fig.update_layout( + plot_bgcolor="white", width=600, height=600, xaxis=dict( @@ -101,7 +112,7 @@ dataset = pybop.Dataset( { "Time [s]": solution["Time [s]"].data, - "Current function [A]": solution["Current [A]"].data, + "Current [A]": solution["Current [A]"].data, "Voltage [V]": corrupt_values, } ) @@ -116,15 +127,13 @@ { "Contact resistance [Ohm]": pybop.Parameter( initial_value=initial_value[0], - prior=pybop.Gaussian(0.02, 0.005), + distribution=pybop.Gaussian(0.02, 0.005, truncated_at=[0.005, 0.025]), transformation=pybop.ScaledTransformation(coefficient=200), - bounds=[0.005, 0.025], ), "Negative particle diffusivity [m2.s-1]": pybop.Parameter( initial_value=initial_value[1], - prior=pybop.Gaussian(9e-14, 2e-14), + distribution=pybop.LogUniform(1.9e-14, 12e-14), transformation=pybop.LogTransformation(), - bounds=[1.9e-14, 12e-14], ), } ) @@ -143,6 +152,8 @@ steps=25, title=None, show=False, + ) + landscape_fig.update_layout( xaxis=dict( title=dict(text="Contact resistance / Ω", font_size=axis_font_size), tickfont_size=tick_font_size, @@ -241,13 +252,14 @@ # Plot convergence cost_log = result.cost_convergence iteration_numbers = list(range(1, len(cost_log) + 1)) - convergence_plot_dict = pybop.plot.StandardPlot( + convergence_plot = go.Scatter( x=iteration_numbers, y=cost_log, - trace_names=[cost.name], - trace_options={"line": {"width": 4, "dash": "dash"}}, + mode="lines", + name=cost.name, + line=dict(width=4, dash="dash"), ) - convergence_traces.extend(convergence_plot_dict.traces) + convergence_traces.append(convergence_plot) # Plot minimising convergence traces together convergence_fig = go.Figure( @@ -293,22 +305,22 @@ first_MAP = True for cost in maximising_cost_classes: + # Define the cost and problem if cost is pybop.GaussianLogLikelihoodKnownSigma: - cost = cost(dataset, sigma0=sigma) + cost = cost(dataset, sigma=sigma) + problem = pybop.Problem(simulator, cost) elif cost is pybop.GaussianLogLikelihood: - cost = cost(dataset, sigma0=4 * sigma) + cost = cost(dataset, sigma=4 * sigma) + problem = pybop.Problem(simulator, cost) elif cost is pybop.LogPosterior and first_MAP: - cost = cost( - log_likelihood=pybop.GaussianLogLikelihoodKnownSigma( - dataset, sigma0=sigma - ) - ) + cost = pybop.GaussianLogLikelihoodKnownSigma(dataset, sigma=sigma) + problem = pybop.LogPosterior(simulator, cost) first_MAP = False elif cost is pybop.LogPosterior: - cost = cost(log_likelihood=pybop.GaussianLogLikelihood(dataset)) + cost = pybop.GaussianLogLikelihood(dataset) + problem = pybop.LogPosterior(simulator, cost) - # Define the problem and optimiser - problem = pybop.Problem(simulator, cost) + # Define the optimiser options = pybop.SciPyMinimizeOptions(maxiter=50, method="BFGS", jac=True) optim = pybop.SciPyMinimize(problem, options=options) @@ -320,17 +332,19 @@ # Plot convergence cost_log = result.cost_convergence iteration_numbers = list(range(1, len(cost_log) + 1)) - convergence_plot_dict = pybop.plot.StandardPlot( + convergence_plot = go.Scatter( x=iteration_numbers, y=cost_log, - trace_names=cost.name - + " " - + ( - cost.log_likelihood.name if isinstance(cost, pybop.LogPosterior) else "" + mode="lines", + name=pybop.plot.wrap_text( + ("Log Posterior " if isinstance(problem, pybop.LogPosterior) else "") + + cost.name, + width=40, + backend="plotly", ), - trace_options={"line": {"width": 4, "dash": "dash"}}, + line={"width": 4, "dash": "dash"}, ) - convergence_traces.extend(convergence_plot_dict.traces) + convergence_traces.extend([convergence_plot]) # Plot maximising convergence traces together convergence_fig = go.Figure( @@ -476,8 +490,10 @@ result, steps=25, title="", - showlegend=False, show=False, + ) + contour.update_layout( + showlegend=False, margin=dict(l=20, r=20, t=20, b=20), ) if i == num_optimisers - 1: @@ -506,6 +522,7 @@ tickfont_size=tick_font_size, linewidth=1, linecolor="black", + showexponent="last", ), yaxis=dict( title=dict( @@ -518,6 +535,7 @@ linewidth=1, linecolor="black", range=bounds[0], + showexponent="last", ), legend=dict( yanchor="bottom", y=1.02, xanchor="left", x=-0.05, font_size=tick_font_size @@ -542,7 +560,9 @@ linewidth=1, linecolor="black", range=bounds[1], + showexponent="last", ), + margin=dict(l=10, r=10, b=10, t=75, pad=4), ) parameter_fig.data = [] parameter_fig.add_traces(parameter_traces) @@ -587,8 +607,10 @@ result, steps=25, title="", - showlegend=False, show=False, + ) + contour.update_layout( + showlegend=False, margin=dict(l=20, r=20, t=20, b=20), ) contour.update_traces(showscale=False, selector=dict(type="contour")) @@ -642,7 +664,9 @@ linewidth=1, linecolor="black", range=bounds[1], + showexponent="last", ), + margin=dict(l=10, r=10, b=10, t=75, pad=4), ) parameter_fig.data = [] parameter_fig.add_traces(parameter_traces) @@ -688,8 +712,10 @@ result, steps=25, title="", - showlegend=False, show=False, + ) + contour.update_layout( + showlegend=False, margin=dict(l=20, r=20, t=20, b=20), ) contour.update_traces(showscale=False, selector=dict(type="contour")) @@ -773,7 +799,9 @@ linewidth=1, linecolor="black", range=bounds[1], + showexponent="last", ), + margin=dict(l=10, r=10, b=10, t=75, pad=4), ) parameter_fig.data = [] parameter_fig.add_traces(parameter_traces) @@ -785,26 +813,44 @@ if create_plot["posteriors"]: sigma0 = pybop.Parameter( initial_value=sigma, - prior=pybop.Uniform(1e-8 * sigma, 10 * sigma), bounds=[1e-8, 10 * sigma], ) - likelihood = pybop.GaussianLogLikelihood(dataset, sigma0=sigma0) - posterior = pybop.Problem(simulator, pybop.LogPosterior(likelihood)) + parameter_values2 = pybamm.ParameterValues("Chen2020") + parameter_values2["Contact resistance [Ohm]"] = 0.01 + parameter_values2.update( + { + "Contact resistance [Ohm]": pybop.Parameter( + initial_value=initial_value[0], + distribution=pybop.Gaussian(0.02, 0.005, truncated_at=[0.005, 0.025]), + transformation=pybop.ScaledTransformation(coefficient=200), + ), + "Negative particle diffusivity [m2.s-1]": pybop.Parameter( + initial_value=initial_value[1], + distribution=pybop.Gaussian( + 9e-14, 2e-14, truncated_at=[1.9e-14, 12e-14] + ), + ), + } + ) + simulator = pybop.pybamm.Simulator( + model, parameter_values=parameter_values2, protocol=dataset, solver=solver + ) + likelihood = pybop.GaussianLogLikelihood(dataset, sigma=sigma0) + posterior = pybop.LogPosterior(simulator, likelihood) options = pybop.PintsSamplerOptions( n_chains=5, max_iterations=3500, warm_up_iterations=1500, - cov=posterior.parameters.get_sigma0(transformed=True), ) sampler = pybop.HaarioBardenetACMC(posterior, options=options) result = sampler.run() print(result) print("True parameter values:", [true_value, sigma]) - summary = pybop.PosteriorSummary(result.chains) - print(summary.rhat()) - print(summary.effective_sample_size(mixed_chains=True)) + summary = result.get_summary_statistics() + print(result.rhat()) + print(result.effective_sample_size(mixed_chains=True)) # Create a grid for subplots fig = plt.figure(figsize=(15, 6)) @@ -822,7 +868,7 @@ def format_axis(ax): # Subplot for parameter 0 ax1 = fig.add_subplot(gs[0, 0]) ax1.hist( - summary.all_samples[:, 0], + result.all_samples[:, 0], bins=50, density=False, alpha=0.6, @@ -833,8 +879,8 @@ def format_axis(ax): ax1.set_ylim(0, 775) ax1.tick_params(axis="both", which="major", labelsize=tick_font_size) ax1.axvspan( - summary.get_summary_statistics()[("ci_lower")][0], - summary.get_summary_statistics()[("ci_upper")][0], + summary[("ci_lower")][0], + summary[("ci_upper")][0], alpha=0.1, color="tab:blue", ) @@ -844,7 +890,7 @@ def format_axis(ax): # Subplot for parameter 1 ax2 = fig.add_subplot(gs[0, 1]) ax2.hist( - summary.all_samples[:, 1], + result.all_samples[:, 1], bins=50, density=False, alpha=0.6, @@ -855,8 +901,8 @@ def format_axis(ax): ax2.set_ylim(0, 775) ax2.tick_params(axis="both", which="major", labelsize=tick_font_size) ax2.axvspan( - summary.get_summary_statistics()[("ci_lower")][1], - summary.get_summary_statistics()[("ci_upper")][1], + summary[("ci_lower")][1], + summary[("ci_upper")][1], alpha=0.1, color="tab:red", ) @@ -866,7 +912,7 @@ def format_axis(ax): # Subplot for sigma ax3 = fig.add_subplot(gs[0, 2]) ax3.hist( - summary.all_samples[:, 2], + result.all_samples[:, 2], bins=50, density=False, alpha=0.6, @@ -876,8 +922,8 @@ def format_axis(ax): ax3.set_ylim(0, 775) ax3.tick_params(axis="both", which="major", labelsize=tick_font_size) ax3.axvspan( - summary.get_summary_statistics()[("ci_lower")][2], - summary.get_summary_statistics()[("ci_upper")][2], + summary[("ci_lower")][2], + summary[("ci_upper")][2], alpha=0.1, color="tab:purple", ) @@ -954,19 +1000,35 @@ def noise(sigma, values): problem, result.best_inputs, title="", + show=False, + ) + parameter_fig.update_layout( width=600, height=600, margin=dict(t=60, b=84, r=50, l=15), - xaxis=dict(title_font_size=axis_font_size, linewidth=1), - yaxis=dict(title_font_size=axis_font_size, linewidth=1), + xaxis=dict( + title_font_size=axis_font_size, + linewidth=1, + showline=True, + linecolor="black", + ), + yaxis=dict( + title_font_size=axis_font_size, + linewidth=1, + showline=True, + linecolor="black", + ), ) - parameter_fig[0].data[1].update(line=dict(color="#00CC97")) - parameter_fig[0].write_image("figures/individual/impedance_spectrum.pdf") + parameter_fig.data[1].update(line=dict(color="#00CC97")) + parameter_fig.write_image("figures/individual/impedance_spectrum.pdf") landscape_fig = pybop.plot.contour( problem, steps=25, show=False, + title=None, + ) + landscape_fig.update_layout( xaxis=dict( title=dict(text="Contact resistance / Ω", font_size=axis_font_size), tickfont_size=tick_font_size, @@ -990,7 +1052,6 @@ def noise(sigma, values): ), coloraxis_colorbar=dict(tickfont_size=tick_font_size), margin=dict(t=50), - title=None, ) landscape_fig.add_trace( go.Scatter( diff --git a/pybop/plot/__init__.py b/pybop/plot/__init__.py index 8c669df92..cd4fbf9fe 100644 --- a/pybop/plot/__init__.py +++ b/pybop/plot/__init__.py @@ -1,8 +1,20 @@ +# Plotting backend default +DEFAULT_BACKEND = 'matplotlib' +current_backend=DEFAULT_BACKEND + +from .util import ( + use_backend, + get_backend, + get_backend_from_figure, + parse_data, + remove_brackets, + wrap_text +) + # # Import plots # -from .plotly_manager import PlotlyManager -from .standard_plots import StandardPlot, StandardSubplot, trajectories +from .trajectories import trajectories from .contour import contour from .dataset import dataset from .convergence import convergence @@ -13,3 +25,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..4059db937 --- /dev/null +++ b/pybop/plot/backends/base.py @@ -0,0 +1,450 @@ +import warnings +from abc import ABC, abstractmethod + +import numpy as np + + +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. + """ + + @property + def name(self): + """ + Return the name of the backend. + + Returns + ------- + str + Name of the backend. + """ + return self.__class__.__name__.replace("Backend", "").lower() + + @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, + num_rows: int, + num_cols: int, + num_plots: int, + title=None, + style=None, + ): + """ + Create a figure containing multiple subplot axes. + + Parameters + ---------- + num_rows : int + Number of rows in the subplot grid. + num_cols : int + Number of columns in the subplot grid. + num_plots : int + Total number of subplots to create. + title : str, optional + Figure title. + style : dict, optional + Backend-specific styling options. + + Returns + ------- + fig : object + Backend-specific figure object. + axes : list + List of subplot axes. + """ + raise NotImplementedError + + @abstractmethod + def legend(self, fig, ax=None, style: dict = None): + """ + Configure or display a legend for a figure. + + Parameters + ---------- + fig : object + Figure object. + style : dict, optional + Legend styling options. + ax : object, optional + Subplot axis to apply the legend to (if applicable). + """ + raise NotImplementedError + + @abstractmethod + def show_figure(self, fig): + """ + Render or display a figure. + + Parameters + ---------- + fig : object + Figure to display. + """ + raise NotImplementedError + + def parse_input_axes(self, figures, axes, num_plots=None, allow_single_axis=True): + """ + Parse and validate input figures and axes for plotting. + + Parameters + ---------- + figures : object or list + Figure(s) to plot on. + axes : object or list + Axis/axes to plot on. + num_plots : int, optional + Expected number of axes for the plot. + allow_single_axis : bool, optional + Whether to allow a single axis for multiple plots. + + Returns + ------- + figures : list + List of figure objects. + axes : list + List of axis objects. + create_figure : bool + Whether a new figure should be created. + single_axis : bool + Whether a single axis is being used for multiple plots. + """ + if figures is None: + if axes is not None: + warnings.warn( + "Axes argument ignored if no figure provided.", + UserWarning, + stacklevel=2, + ) + return [], [None], True, False + + figures = figures if hasattr(figures, "__len__") else [figures] + axes = axes if hasattr(axes, "__len__") else [axes] + if not len(figures) == len(axes): + if len(figures) == 1: + figures = np.asarray([figures[0]] * len(axes)) + elif axes[0] is None: + axes = np.asarray([axes[0]] * len(figures)) + else: + raise ValueError( + "Please provide the same number of figures and axes or only one figure." + ) + if num_plots is not None and len(axes) != num_plots: + if not allow_single_axis: + raise ValueError( + f"This plot requires {num_plots} axes. {len(axes)} axes provided." + ) + elif len(axes) != 1: + raise ValueError( + f"This plot requires either {num_plots} axes or a single axis. {len(axes)} axes provided." + ) + + return figures, axes, False, len(axes) == 1 + + @abstractmethod + def update_axes_titles(self, figs, axes, xaxis_titles, yaxis_titles): + """ + Update the titles of the axes in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of figures containing the axes to update. + axes : list[tuple] + List of subplot locations. + xaxis_titles : list[str] + List of titles for the X-axes. + yaxis_titles : list[str] + List of titles for the Y-axes. + max_width : int, optional + Maximum width for the axis titles before wrapping. Default is 40 characters. + """ + raise NotImplementedError + + @abstractmethod + def update_plot_titles(self, figs, axes, titles, pad): + """ + Update the titles of the subplots in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of figures containing the subplots to update. + axes : list[tuple] + List of subplot locations. + titles : list[str] + List of titles for the subplots. + max_text_width : int, optional + Maximum width for the subplot titles before wrapping. Default is 40 characters. + pad : int, optional + Padding between the title and the subplot. Default is 0. + """ + raise NotImplementedError + + @abstractmethod + def update_axes_ranges(self, fig, ax, xaxis_range, yaxis_range): + """ + Update the ranges of the axes in the provided figure. + + Parameters + ---------- + fig : Figure + Figure containing the axes to update. + ax : tuple + Subplot location. + xaxis_range : tuple + Range for the x-axis. + yaxis_range : tuple + Range for the y-axis. + """ + 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, fig, ax=None, colorscale="viridis"): + """ + Create a contour plot. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Surface values. + colorscale : str, optional + Colour scale name. + fig : object + Target figure. + ax : object, optional + Target subplot axis. + + Returns + ------- + object + Backend-specific contour trace or figure. + """ + 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 heatmap(self, x, y, z, colorscale="viridis"): + """ + Create a heatmap. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Heatmap values. + colorscale : str, optional + Colour scale name. + + Returns + ------- + object + Backend-specific heatmap trace or figure. + """ + 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..4c10ae075 --- /dev/null +++ b/pybop/plot/backends/matplotlib.py @@ -0,0 +1,696 @@ +import numpy as np + +from pybop.plot.backends.base import PlotBackend +from pybop.plot.util import 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 + + # 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). + """ + if "width" in style or "height" in style: + return ( + np.ceil(style.get("width", 800) / 100), + np.ceil(style.get("height", 600) / 100), + ) + else: + return None + + 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)) + + 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. + self.update_axes_ranges( + fig, + xaxis_range=style.get("xaxis_range"), + yaxis_range=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, + num_rows: int, + num_cols: int, + num_plots: int, + title=None, + style=None, + ): + """ + Create a figure containing multiple subplot axes in a grid. + + Parameters + ---------- + num_rows : int + Number of rows in the subplot grid. + num_cols : int + Number of columns in the subplot grid. + num_plots : int + Total number of subplots to create. + title : str, optional + Figure title. + 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 {} + if (num_rows * num_cols) < num_plots: + raise ValueError( + f"Insufficient subplots: {num_rows} rows and {num_cols} columns " + f"cannot accommodate {num_plots} plots." + ) + + # Create figure + fig, axes = self.plt.subplots( + num_rows, num_cols, figsize=self._figsize(style), dpi=100 + ) + axes = np.atleast_1d(axes).flatten() + for ax in axes[num_plots:]: + ax.set_visible(False) + + if title is not None: + self.plt.suptitle(title) + + for ax in fig.axes: + 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[:num_plots] + + def legend(self, fig, axes=None, 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 + elif axes is not None: + axes = np.atleast_1d(axes) + else: + axes = [fig.gca()] + + if "loc" in style: + anchors = style.get("loc").split(" ") + if len(anchors) != 2: + raise ValueError("loc property must consist of 2 keywords") + + # 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[-1].legend(lines, labels, **opts) + + def update_axes_titles( + self, figures, axes, xaxis_titles, yaxis_titles, max_width=40 + ): + """ + Update the titles of the axes in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of matplotlib figures containing the axes to update. + axes : list[tuple] + List of subplot locations specified as matplotlib axes objects. + xaxis_titles : list[str] + List of titles for the X-axes. + yaxis_titles : list[str] + List of titles for the Y-axes. + max_width : int, optional + Maximum width for the axis titles before wrapping. Default is 40 characters. + """ + xaxis_titles = np.atleast_1d(xaxis_titles) + yaxis_titles = np.atleast_1d(yaxis_titles) + figures = np.atleast_1d(figures) + + for i, ax in enumerate(np.atleast_1d(axes)): + if ax is None: + ax = figures[i % len(figures)].gca() + xaxis_title = xaxis_titles[i % len(xaxis_titles)] + if xaxis_title is not None: + xaxis_title = wrap_text(xaxis_title, width=max_width) + ax.set_xlabel(xaxis_title) + yaxis_title = yaxis_titles[i % len(yaxis_titles)] + if yaxis_title is not None: + yaxis_title = wrap_text(yaxis_title, width=max_width) + ax.set_ylabel(yaxis_title) + + def update_plot_titles(self, figures, axes, titles, max_text_width=40, pad=0): + """ + Update the titles of the subplots in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of matplotlib figures containing the subplots to update. + axes : list[tuple] + List of subplot locations specified as matplotlib axes objects. + titles : list[str] + List of titles for the subplots. + max_text_width : int, optional + Maximum width for the subplot titles before wrapping. Default is 40 characters. + pad : int, optional + Padding between the title and the subplot. Default is 0. + """ + titles = np.atleast_1d(titles) + figures = np.atleast_1d(figures) + for i, ax in enumerate(np.atleast_1d(axes)): + if ax is None: + ax = figures[i].gca() + title = titles[i % len(titles)] + if title is not None: + title = wrap_text(title, width=max_text_width) + ax.set_title(title, pad=pad) + + def update_axes_ranges(self, fig, ax=None, xaxis_range=None, yaxis_range=None): + """ + Update the ranges of the axes in the provided figure. + + Parameters + ---------- + fig : Figure + Matplotlib figure containing the axes to update. + ax : tuple + Subplot location. + xaxis_range : tuple + Range for the x-axis. + yaxis_range : tuple + Range for the y-axis. + """ + ax = ax or fig.gca() + if xaxis_range is not None: + ax.set_xlim(xaxis_range) + if yaxis_range is not None: + ax.set_ylim(yaxis_range) + + 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, np.ndarray, tuple)): + 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() + fig.sca(ax) + 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" + and "color" not in options + and "markeredgecolor" not in options + and "markerfacecolor" not in options + ): + 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, ax=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 + ax: matplotlib axis object, optional + Specity an axis for plotting. Otherwise current axis is used. + """ + # Get axis + ax = ax or fig.gca() + + # 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) + + # Use AxisDivider to adjust position and size of the colourbar relative to the target axis. + from mpl_toolkits.axes_grid1 import make_axes_locatable + + divider = make_axes_locatable(ax) + cax = divider.append_axes("right", size="5%", pad=0.05) + + # Create and attach a standalone colourbar. + cmap = self.mpl.colormaps[colorscale] + self.plt.colorbar( + self.mpl.cm.ScalarMappable(norm=norm, cmap=cmap), + ax=ax, + label=label, + cax=cax, + ) + fig.sca(ax) + + def contour_plot(self, x, y, z, fig, ax=None, 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. + fig : matplotlib.figure.Figure + The figure. + ax : matplotlib axis object, optional + Specity an axis for plotting. Otherwise current axis is used. + + 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", + ) + self.plot_trace(contour, fig, ax=ax) + self.plot_trace(contour_lines, fig, ax=ax) + + 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 heatmap(self, x, y, z, colorscale="viridis"): + """ + Return a trace definition for a heatmap. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Surface values. + colorscale : str, optional + Colour scale name. + """ + return { + "positional_args": [z], + "plot_type": "imshow", + "cmap": colorscale, + "extent": (np.min(x), np.max(x), np.min(y), np.max(y)), + "origin": "lower", + "interpolation": "nearest", + } + + 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, fig=None, ax=None): + """ + 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. + fig : matplotlib.figure.Figure, optional + Figure for plotting. If not provided a new figure is created. + ax : matplotlib axis object, optional + Axis for plotting. If not provided the current axis is used. + """ + for i, val in enumerate(values): + values[i] = [val[0], ", ".join(val[1].astype(str))] + + if fig is None: + fig = self.plt.figure(figsize=(6, 2), dpi=100) + + ax = ax or fig.gca() + + # 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) + return fig + + def vline(self, fig, x, style=None, ax=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 + ax: matplotlib axis object, optional + Specity an axis for plotting. Otherwise current axis is used. + """ + ax = ax or fig.gca() + style = style or {} + ax.axvline(x, **style) diff --git a/pybop/plot/backends/plotly.py b/pybop/plot/backends/plotly.py new file mode 100644 index 000000000..5e2df966d --- /dev/null +++ b/pybop/plot/backends/plotly.py @@ -0,0 +1,890 @@ +import numbers + +import numpy as np + +from pybop.plot.backends.base import PlotBackend +from pybop.plot.backends.plotly_manager import PlotlyManager +from pybop.plot.util import 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.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"), + } + + @staticmethod + def _check_axis_input(axis, raise_error=True): + """ + Validate that the axis input is a tuple of two numbers. + + Parameters + ---------- + axis : tuple + Axis input to validate. + + Raises + ------ + ValueError + If the axis input is not a tuple of two numbers. + """ + if (axis is not None) and ( + not isinstance(axis, (tuple, np.ndarray)) + or len(axis) != 2 + or not all(isinstance(x, numbers.Number) for x in axis) + ): + if raise_error: + raise ValueError( + "Axis must be a tuple of the form (row, col) with numeric values." + ) + else: + return False + return True + + 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 + - height 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 make_subplots( + self, + num_rows: int, + num_cols: int, + num_plots: int, + title=None, + style=None, + ): + """ + Create a figure containing multiple subplots. + + Parameters + ---------- + num_rows : int + Number of rows in the subplot grid. + num_cols : int + Number of columns in the subplot grid. + num_plots : int + Total number of subplots to create. + 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 {} + + if (num_rows * num_cols) < num_plots: + raise ValueError( + f"Insufficient subplots: {num_rows} rows and {num_cols} columns " + f"cannot accommodate {num_plots} plots." + ) + + # Create figure with supbplots + make_subplots = self.plotly_manager.make_subplots + fig = make_subplots( + rows=num_rows, + cols=num_cols, + horizontal_spacing=0.2, + vertical_spacing=0.15, + ) + + axes = [ + (row, col) + for row in range(1, num_rows + 1) + for col in range(1, num_cols + 1) + ] + + fig.update_layout(self._figure_layout(style, title)) + + return fig, axes[:num_plots] + + def legend(self, fig, axes=None, 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. + axes : tuple, optional + Subplot axis to apply the legend to (if applicable). + """ + 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] + + # Ignore options if axes is not None + if axes is not None: + opts = {} + + 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: + # show duplicate figures only once + seen = set() + for f in fig: + if id(f) not in seen: + seen.add(id(f)) + f.show() + else: + fig.show() + + def parse_input_axes(self, figures, axes, num_plots=None, allow_single_axis=True): + """ + Parse and validate the input axes for plotting. + + Parameters + ---------- + figures : Figure or list[Figure] + Plotly figure(s) to which the axes belong. + axes : tuple or list[tuple] + Subplot locations specified as (row, col) tuples. + num_plots : int, optional + Number of plots to be displayed. + allow_single_axis : bool, optional + If True, a single axis can be used for all plots. + + Returns + ------- + tuple + ( + list of figures, + list of axes, + number of rows, + number of columns + ) + Raises + """ + if axes is not None: + if self._check_axis_input(axes, raise_error=False): + axes = [axes] + else: + axes = list(axes) + for ax in axes: + self._check_axis_input(ax) + return super().parse_input_axes( + figures, axes, num_plots, allow_single_axis=allow_single_axis + ) + + def update_axes_titles( + self, figures, axes, xaxis_titles, yaxis_titles, max_width=40 + ): + """ + Update the titles of the axes in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of plotly figures containing the axes to update. + axes : list[tuple] + List of subplot locations specified as (row, col) tuples. + xaxis_titles : list[str] + List of titles for the X-axes. + yaxis_titles : list[str] + List of titles for the Y-axes. + max_width : int, optional + Maximum width for the axis titles before wrapping. Default is 40 characters. + """ + + figures, axes, _, _ = self.parse_input_axes( + figures, axes, allow_single_axis=True + ) + xaxis_titles = np.atleast_1d(xaxis_titles) + yaxis_titles = np.atleast_1d(yaxis_titles) + for i, ax in enumerate(axes): + # Wrap the axis titles to the specified maximum width + xaxis_title = xaxis_titles[i % len(xaxis_titles)] + if xaxis_title is not None: + xaxis_title = wrap_text(xaxis_title, width=max_width) + yaxis_title = yaxis_titles[i % len(yaxis_titles)] + if yaxis_title is not None: + yaxis_title = wrap_text(yaxis_title, width=max_width) + if ax is None: + figures[i].update_layout(xaxis_title=xaxis_title) + figures[i].update_layout(yaxis_title=yaxis_title) + else: + figures[i].update_xaxes( + title_text=xaxis_title, + row=ax[0], + col=ax[1], + ) + figures[i].update_yaxes( + title_text=yaxis_title, + row=ax[0], + col=ax[1], + ) + + def update_plot_titles(self, figures, axes, titles, max_text_width=40, pad=0): + """ + Update the titles of the subplots in the provided figures. + + Parameters + ---------- + figures : list[Figure] + List of plotly figures containing the subplots to update. + axes : list[tuple] + List of subplot locations specified as (row, col) tuples. + titles : list[str] + List of titles for the subplots. + max_text_width : int, optional + Maximum width for the subplot titles before wrapping. Default is 40 characters. + pad : int, optional + Padding between the title and the subplot. Default is 0. + Ignored for plotly. Added for consistency with matplotlib backend. + + """ + titles = np.atleast_1d(titles) + figures, axes, _, _ = self.parse_input_axes( + figures, axes, allow_single_axis=True + ) + for i, ax in enumerate(axes): + title = titles[i % len(titles)] + if title is not None: + title = wrap_text(title, width=max_text_width) + if title is None: + continue + if ax is None: + figures[i].update_layout(title=title) + else: + figures[i].add_annotation( + xref="x domain", + yref="y domain", + x=0.0, + y=1.05, + showarrow=False, + text=title, + row=ax[0], + col=ax[1], + font=dict(size=14), + ) + + def update_axes_ranges(self, fig, ax, xaxis_range, yaxis_range): + """ + Update the ranges of the axes in the provided figure. + + Parameters + ---------- + fig : Figure + Plotly figure containing the axes to update. + ax : tuple + Subplot location. + xaxis_range : tuple + Range for the x-axis. + yaxis_range : tuple + Range for the y-axis. + """ + if ax is None: + fig.update_layout(xaxis_range=xaxis_range, yaxis_range=yaxis_range) + else: + self._check_axis_input(ax) + fig.update_xaxes(range=xaxis_range, row=ax[0], col=ax[1]) + fig.update_yaxes(range=yaxis_range, row=ax[0], col=ax[1]) + + 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 : tuple, optional + Subplot location. If provided, traces are added + to the specified subplot. + + Returns + ------- + None + """ + for trace in np.atleast_1d(traces): + if isinstance(trace, self.plotly_manager.go.Histogram): + # Use barmode='overlay' for histograms + fig.update_layout(barmode="overlay") + if ax is None: + fig.add_trace(trace) + else: + self._check_axis_input(ax) + fig.add_trace(trace, row=ax[0], col=ax[1]) + + 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, ax=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"}}) + + if ax is not None: + self._check_axis_input(ax) + xaxis = next(fig.select_xaxes(row=ax[0], col=ax[1])) + yaxis = next(fig.select_yaxes(row=ax[0], col=ax[1])) + colorbar.update( + yanchor="bottom", + y=yaxis.domain[0], + x=xaxis.domain[1], + len=yaxis.domain[1] - yaxis.domain[0], + ) + + # Plotly requires a trace to render a standalone colour bar, so an + # invisible scatter trace is added solely to display the scale. + 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", + ) + self.plot_trace(trace, fig, ax) + + def contour_plot(self, x, y, z, fig, ax, 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. + fig : plotly.graph_objects.Figure + Target figure. + ax : tuple, optional + Subplot location. If provided, the contour trace is added to the specified subplot. + + Returns + ------- + plotly.graph_objects.Contour + Contour trace. + """ + colorbar = {} + if ax is not None: + self._check_axis_input(ax) + xaxis = next(fig.select_xaxes(row=ax[0], col=ax[1])) + yaxis = next(fig.select_yaxes(row=ax[0], col=ax[1])) + colorbar.update( + yanchor="bottom", + y=yaxis.domain[0], + x=xaxis.domain[1], + len=yaxis.domain[1] - yaxis.domain[0], + ) + + # Use connectgaps=True to ill small gaps in the input grid to avoid breaks in contour regions. + trace = self.plotly_manager.go.Contour( + x=x, y=y, z=z, colorscale=colorscale, connectgaps=True, colorbar=colorbar + ) + self.plot_trace(trace, fig, ax=ax) + + 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 heatmap(self, x, y, z, colorscale="viridis"): + """ + Create a heatmap trace. + + Parameters + ---------- + x, y : array-like + Coordinate values. + z : array-like + Heatmap values. + colorscale : str, optional + Plotly colour scale. + + Returns + ------- + plotly.graph_objects.Heatmap + Heatmap trace. + """ + return self.plotly_manager.go.Heatmap( + x=x, y=y, z=z, colorscale=colorscale, zsmooth="best", showscale=False + ) + + 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 {} + + if y is None: + raise ValueError("y must be provided") + + 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, fig=None, ax=None): + """ + Display tabular data as a Plotly table. + + Parameters + ---------- + header : list + Column headers. + values : list + Table contents. + title : str + Table title. + fig : plotly.graph_objects.Figure, optional + Figure for plotting. If not provided a new figure is created. + ax : tuple, optional + Subplot location. If provided, the table is added to the specified subplot. + + Returns + ------- + None + + NOTE: There seems to be a bug in plotly https://github.com/plotly/plotly.py/issues/3424 + If any plot with a vline is added to a figure with subplots AFTER a table was added, + this may cause an error. This can be avoided by always adding any tables last. + """ + fig = fig or self.plotly_manager.go.Figure() + + if ax is not None: + # Retrieve subplot domain + self._check_axis_input(ax) + domain = fig.get_subplot(ax[0], ax[1]) + x1, x2 = domain.x + y1, y2 = domain.y + + # Add title as annotation within subplot domain + fig.add_annotation( + xref="paper", + yref="paper", + xanchor="left", + x=x1, + y=y1 + 0.9 * (y2 - y1), + text=title, + showarrow=False, + font=dict(size=14), + ) + + # Manually position table in subplot domain + # This is to enable creating space for the title above the table + trace = self.plotly_manager.go.Table( + domain=dict( + x=[x1, x2], + y=[y1, y1 + 0.8 * (y2 - y1)], # leaves 20% above + ), + header=dict(values=header), + cells=dict( + values=[[row[0] for row in values], [row[1] for row in values]] + ), + ) + else: + fig.update_layout(title=title) + trace = self.plotly_manager.go.Table( + header=dict(values=header), + cells=dict( + values=[[row[0] for row in values], [row[1] for row in values]] + ), + ) + + self.plot_trace(trace, fig) + + return fig + + def vline(self, fig, x, style=None, ax=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. + ax : tuple, optional + Subplot location. If provided, the line is added to the specified subplot. + + Returns + ------- + None + + NOTE: There seems to be a bug in plotly https://github.com/plotly/plotly.py/issues/3424 + If any plot with a vline is added to a figure with subplots AFTER a table was added + (e.g. using show_table), this may cause an error. + This can be avoided by always adding any tables last. + """ + style = style or {} + opts = {} + self._get_line_options(style, opts) + if ax is not None: + self._check_axis_input(ax) + fig.add_vline(x=x, **opts, row=ax[0], col=ax[1]) + else: + 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..c8bf8d68c 100644 --- a/pybop/plot/contour.py +++ b/pybop/plot/contour.py @@ -4,7 +4,8 @@ import numpy as np -from pybop.plot.plotly_manager import PlotlyManager +from pybop.plot.backends import PlotBackend +from pybop.plot.util import get_backend_from_figure from pybop.problems.problem import Problem if TYPE_CHECKING: @@ -17,8 +18,11 @@ def contour( bounds: np.ndarray | None = None, transformed: bool = False, steps: int = 10, + title="Cost Landscape", show: bool = True, - **layout_kwargs, + backend: str | PlotBackend = None, + figures=None, + axes=None, ): """ Plot a 2D visualisation of a cost landscape using Plotly. @@ -32,6 +36,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 +49,30 @@ 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object or list of figure objects, optional + Either a single figure or the same number of figures as axes. + axes: single axis or list of axes, optional + plotly: axes expected to be of the form tuple(row, col) + If gradient is false this must be a single axis. If gradient is + true this must be one axis for the cost contour plot and one + axis for each parameter to plot the gradient. Returns ------- - plotly.graph_objs.Figure - The Plotly figure object containing the cost landscape plot. + None: if show is True + figure object containing the cost landscape: if show is False and gradient is False + tuple (fig, grad_figs): if show is False and gradient is True + fig - figure object containing the cost landscape + grad_figs - list of figure objects containing the gradient for each parameter Raises ------ ValueError If the cost function does not return a valid cost when called with a parameter list. """ + backend = get_backend_from_figure(backend, figures) plot_optim = False problem = call_object @@ -96,6 +111,23 @@ def contour( else: bounds = np.asarray(bounds) + # Process input figures and axes + num_plots = 1 + len(parameters) if gradient else 1 + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=num_plots, allow_single_axis=False + ) + if create_figure: + fig = backend.create_figure( + style={ + "width": 600, + "height": 600, + }, + ) + ax = None + else: + fig = figures[0] + ax = axes[0] + # Generate grid x = np.linspace(bounds[0, 0], bounds[0, 1], steps) y = np.linspace(bounds[1, 0], bounds[1, 1], steps) @@ -143,121 +175,121 @@ 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] - ) - layout_options["yaxis_title"] = ( - "Transformed " + names[1] if transformed else names[1] + backend.update_axes_titles( + fig, + ax, + "Transformed " + names[0] if transformed else names[0], + "Transformed " + names[1] if transformed else names[1], ) - layout = go.Layout(layout_options) + backend.update_plot_titles(fig, ax, title, pad=30) + backend.update_axes_ranges(fig, ax, bounds[0], bounds[1]) # 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.contour_plot(x=x, y=y, z=costs, fig=fig, ax=ax) 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.plot_trace( + backend.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, + ax=ax, ) # 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=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, + ax=ax, ) # Plot optimised value if result.x is not None: x_best = result.x - fig.add_trace( - go.Scatter( + backend.plot_trace( + backend.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, + ax=ax, ) - # Update the layout and display the figure - fig.update_layout(**layout_kwargs) - if show: - fig.show() + backend.legend( + fig, + style={ + "horizontal": True, + "loc": "lower right", + "coords": (1, 1), + }, + axes=ax, + ) 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) + if create_figure: + figures = np.asarray([fig]) + for i, grad_costs in enumerate(grad_parameter_costs): # Create fig - grad_fig = go.Figure( - data=[go.Contour(x=x, y=y, z=grad_costs)], layout=grad_layout + if create_figure: + grad_fig = backend.create_figure( + style={ + "width": 600, + "height": 600, + }, + ) + figures = np.append(figures, grad_fig) + ax = None + else: + ax = axes[i + 1] + grad_fig = figures[i + 1] + + backend.update_plot_titles(grad_fig, ax, f"Gradient for Parameter: {i + 1}") + backend.update_axes_titles( + grad_fig, + ax, + "Transformed " + names[0] if transformed else names[0], + "Transformed " + names[1] if transformed else names[1], ) - grad_fig.update_layout(**layout_kwargs) - if show: - grad_fig.show() + backend.contour_plot(x=x, y=y, z=grad_costs, fig=grad_fig, ax=ax) - # append grad_fig to list - grad_figs.append(grad_fig) + # display the figures + if show: + backend.show_figure(figures) - return fig, grad_figs + return fig, figures[1:] - return fig + # display the figure + if show: + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/convergence.py b/pybop/plot/convergence.py index deabf66d5..74326bc2b 100644 --- a/pybop/plot/convergence.py +++ b/pybop/plot/convergence.py @@ -1,12 +1,14 @@ from typing import TYPE_CHECKING -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend_from_figure, wrap_text 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, figures=None, axes=None +): """ Plot the convergence of the optimisation algorithm. @@ -16,15 +18,20 @@ 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 or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created + axes: axis, optional + The axis to be used for plotting + plotly: axis expected to be of the form tuple(row, col) + Returns --------- - fig : plotly.graph_objs.Figure - The Plotly figure object for the convergence plot. + fig : if show is False; plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the convergence plot. + None : if show is True """ # Extract log from the optimisation object @@ -33,22 +40,40 @@ 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_from_figure(backend, figures) + + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=1 + ) + + # Create figure + if create_figure: + fig = backend.create_figure( + style={"bg_color": "white", "width": 600, "height": 600}, + ) + ax = None + else: + fig = figures[0] + ax = axes[0] + + backend.update_axes_titles(fig, ax, "Evaluation", "Cost") + backend.update_plot_titles(fig, ax, "Convergence") + + # Add line plot + backend.plot_trace( + backend.line( + x=iteration_numbers, + y=cost_log, + label=wrap_text(result.method_name, width=20, backend=backend.name), ), - trace_names=result.method_name, + fig, + ax=ax, ) - # Generate and display the figure - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) - if show: - fig.show() + backend.legend(fig, axes=ax) - return fig + # Display or return figure + if show: + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/dataset.py b/pybop/plot/dataset.py index 24257a732..3518af0b3 100644 --- a/pybop/plot/dataset.py +++ b/pybop/plot/dataset.py @@ -1,7 +1,10 @@ -from pybop.plot.standard_plots import StandardPlot, trajectories +from pybop.plot.trajectories import trajectories +from pybop.plot.util import get_backend_from_figure, 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, figures=None, axes=None +): """ Quickly plot a PyBOP Dataset using Plotly. @@ -11,20 +14,25 @@ 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}}` + backend: str or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created + axes: axis, optional + Thes axis to be used for plotting + plotly: axis expected to be of the form tuple(row, col) Returns ------- - plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. + fig : if show is False; plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the scatter plot. + None : if show is True """ + backend = get_backend_from_figure(backend, figures) # Get data dictionary if signal is None: @@ -34,25 +42,28 @@ 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, + figures=figures, + axes=axes, ) - fig.update_layout(**layout_kwargs) - if show: - fig.show() - return fig + if show: + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/distribution.py b/pybop/plot/distribution.py index e0e0430dc..91e737c5d 100644 --- a/pybop/plot/distribution.py +++ b/pybop/plot/distribution.py @@ -1,31 +1,61 @@ import numpy as np from pybop.parameters.parameter import Parameters -from pybop.plot.standard_plots import StandardSubplot +from pybop.plot.util import get_backend_from_figure, parse_data def distribution( parameters: Parameters, posterior: Parameters | None = None, + title: str = "Prior and Posterior Distributions", n_samples: int = 100, transformed: bool = False, show: bool = True, - **layout_kwargs, + backend: str = None, + figures=None, + axes=None, ): """ Plot the posterior on top of the prior distribution for a Bayesian optimisation result. + + Parameters + ---------- + parameters : pybop.Parameters + The prior distribution of the parameters. + posterior : pybop.Parameters, optional + The posterior distribution of the parameters (default: None). + title : str, optional + The title of the figure (default: "Prior and Posterior Distributions"). + n_samples : int, optional + The number of samples to use for plotting the distributions (default: 100). + transformed : bool, optional + If True, the transformed distributions are plotted (default: False). + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + Can be a single figure or one figure per parameter. + axes: axis, optional + The axes to be used for plotting. One axis per parameter is expected. + plotly: axes expected to be of the form list of tuple(row, col) + + Returns + ------- + fig : if show is False; plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the distribution plot. + Returns a list of figures if multiple figures are provided for plotting. + None : if show is True """ + # 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,23 +67,38 @@ 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), - ) + values, probability = parse_data(values, probability) + + # Get plotting backend + backend = get_backend_from_figure(backend, figures) - # 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, + # Parse figures + num_plots = len(probability) + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=len(labels) ) - fig = plot_dict(show=False) + + # Create subplots for each parameter + if create_figure: + num_cols = int(np.ceil(np.sqrt(num_plots))) + num_rows = int(np.ceil(num_plots / num_cols)) + fig, axes = backend.make_subplots( + num_rows=num_rows, + num_cols=num_cols, + num_plots=num_plots, + title=title, + style={"bg_color": "white", "width": 1600, "height": 800}, + ) + figures = [fig] + + backend.update_axes_titles(figures, axes, xaxis_titles, yaxis_titles) + + for i in range(num_plots): + backend.plot_trace( + backend.line(values[i], probability[i], labels[i]), + figures[i % len(figures)], + ax=axes[i % len(axes)], + ) if posterior is not None: for idx, p in enumerate(posterior): @@ -63,15 +108,23 @@ 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 - ) - row = (idx // plot_dict.num_cols) + 1 - col = (idx % plot_dict.num_cols) + 1 - fig.add_trace(trace, row=row, col=col) + line = backend.line(values[-1], probability[-1], label="Posterior") + ax = axes[idx % len(axes)] + backend.plot_trace(line, figures[idx % len(figures)], ax=ax) - fig.update_layout(**layout_kwargs) + for i, ax in enumerate(axes): + backend.legend( + figures[i % len(figures)], + style=dict( + horizontal=True, + outside=("top", 0.1), + loc="lower right", + coords=(1, 1.02), + fig_legend=True, + ), + axes=ax, + ) if show: - fig.show() - - return fig + backend.show_figure(figures) + else: + return figures[0] if len(figures) == 1 else figures diff --git a/pybop/plot/nyquist.py b/pybop/plot/nyquist.py index 80f7eb77a..f5fbc3282 100644 --- a/pybop/plot/nyquist.py +++ b/pybop/plot/nyquist.py @@ -1,8 +1,18 @@ +import numpy as np + from pybop.parameters.parameter import Inputs -from pybop.plot.standard_plots import StandardPlot +from pybop.plot.util import get_backend_from_figure -def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): +def nyquist( + problem, + inputs: Inputs = None, + show=True, + title="Nyquist Plot", + backend=None, + figures=None, + axes=None, +): """ Generates Nyquist plots for the given problem by evaluating the model's output and target values. @@ -11,27 +21,32 @@ 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object or list of figure objects, optional + Either a single figure or the same number of figures as axes. + axes: single axis or list of axes, optional + plotly: axes expected to be of the form tuple(row, col) + Number of axes must agree with number of targets for the problem. Returns ------- - list - A list of plotly `Figure` objects, each representing a Nyquist plot for the model's output and target values. - + fig or list of figs : plotly.graph_objs.Figure or matplotlib.figure.Figure + A single figure or a list of figures containing the Nyquist plots for each target in the + problem. If show is True, the figures will be displayed and None will be returned. Notes ----- - The function extracts the real part of the impedance from the model's output and the real and imaginary parts 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,6 +54,24 @@ 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. """ + # Import plotting backend + backend = get_backend_from_figure(backend, figures) + + # Process input figures + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=len(problem.target), allow_single_axis=False + ) + + 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) @@ -46,74 +79,44 @@ def nyquist(problem, inputs: Inputs = None, show=True, **layout_kwargs): domain_data = model_output["Impedance"].data.real target_output = problem.target_data - figure_list = [] - 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, - ) + for i, var in enumerate(problem.target): + if create_figure: + fig = backend.create_figure( + style={"width": 600, "height": 600, "bg_color": "white"}, + ) + figures = np.append(figures, fig) + ax = None + else: + fig = figures[i] + ax = axes[i] - plot_dict = StandardPlot( - x=domain_data, - y=-model_output[var].data.imag, - layout_options=default_layout_options, - trace_names="Model", - ) + backend.update_axes_titles(fig, ax, r"$Z_{re} / \Omega$", r"$-Z_{im} / \Omega$") + backend.update_plot_titles(fig, ax, title) - 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, + ax=ax, ) - 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, + ax=ax, ) - plot_dict.traces.append(target_trace) - - fig = plot_dict(show=False) - - # Overwrite with user-kwargs - fig.update_layout(**layout_kwargs) - if show: - fig.show() - - figure_list.append(fig) + backend.legend(fig, axes=ax) - return figure_list + if show: + backend.show_figure(figures) + else: + return figures[0] if len(figures) == 1 else figures diff --git a/pybop/plot/parameters.py b/pybop/plot/parameters.py index a13d9e3fb..5f369862b 100644 --- a/pybop/plot/parameters.py +++ b/pybop/plot/parameters.py @@ -1,13 +1,20 @@ +import math from typing import TYPE_CHECKING -from pybop.costs.log_likelihoods import GaussianLogLikelihood -from pybop.plot.standard_plots import StandardSubplot +from pybop.plot.util import get_backend_from_figure, parse_data if TYPE_CHECKING: from pybop._result import Result -def parameters(result: "Result", show=True, **layout_kwargs): +def parameters( + result: "Result", + title: str = "Parameter Convergence", + show: bool = True, + backend: str = None, + figures=None, + axes=None, +): """ Plot the evolution of parameters during the optimisation process using Plotly. @@ -15,56 +22,89 @@ def parameters(result: "Result", show=True, **layout_kwargs): ---------- result : pybop.Result Optimisation result containing the history of parameter values and associated cost. + title : str, optional + The title of the plot (default: "Parameter Convergence"). 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used + figures: figure object , optional + Figure for plotting. If not provided a new figure is created. + Can be a single figure or one figure per parameter. + axes: single axis or list of axes, optional + axes for plotting + plotly: axes expected to be of the form tuple(row, col) + Number of axis must either agree with the number of parameters or + be a single axis for all parameters. Returns ------- - plotly.graph_objs.Figure - A Plotly figure object showing the parameter evolution over iterations. + fig : if show is False; plotly.graph_objs.Figure or matplotlib.figure.Figure + The figure object for the parameter plot. + Returns a list of figures if multiple figures are provided for plotting. + None : if show is True """ + # import plotting backend + backend = get_backend_from_figure(backend, figures) # Extract parameters and log from the optimisation object parameters = result.problem.parameters x = list(range(len(result.x_model))) y = [list(item) for item in zip(*result.x_model, strict=False)] + x, y = parse_data(x, y) # 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") - - # 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), + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=len(labels) ) - # 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, + for name in labels: + xaxis_titles.append("Evaluation") + yaxis_titles.append( + name if axes[0] is None or len(axes) == len(labels) else "Parameter Value" + ) + + # legend style + style = ( + { + "fig_legend": True, + "outside": ("right", 0.18), + } + if figures is None + else {} ) + if create_figure: + # Create a subplot for each parameter + num_cols = int(math.ceil(math.sqrt(len(labels)))) + num_rows = int(math.ceil(len(labels) / num_cols)) + fig, axes = backend.make_subplots( + num_rows=num_rows, + num_cols=num_cols, + num_plots=len(labels), + title=title, + style=dict(bg_color="white", width=1600, height=800), + ) + figures = [fig] + + backend.update_axes_titles(figures, axes, xaxis_titles, yaxis_titles) + for i in range(len(labels)): + backend.plot_trace( + backend.line(x[i % len(x)], y[i], labels[i]), + figures[i % len(figures)], + ax=axes[i % len(axes)], + ) + + # add legend + for i, ax in enumerate(axes): + backend.legend(figures[i % len(figures)], style=style, axes=ax) + # Generate the figure and update the layout - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) if show: - fig.show() - - return fig + backend.show_figure(figures) + else: + return figures[0] if len(figures) == 1 else figures diff --git a/pybop/plot/predictive.py b/pybop/plot/predictive.py index 25211d6d5..90f3d2bb1 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_from_figure, remove_brackets from pybop.problems.meta_problem import MetaProblem from pybop.simulators.failed_solution import FailedSolution @@ -21,14 +20,64 @@ def predictive( pdf_label: str = "PDF", colour_scale="viridis", show: bool = True, - **layout_kwargs, + backend: str | None = None, + figures=None, + axes=None, ): """ Plot the predictive posterior of a Bayesian optimisation result. + + Parameters + ---------- + result : pybop.BayesianOptimisationResult or pybop.SamplingResult + The result of the Bayesian optimisation or sampling process. + number_of_traces : int, optional + The number of posterior predictive traces to plot (default: 8). + data_legend_entry : str, optional + The legend entry for the observed data (default: None). + rvs_legend_entry : str, optional + The legend entry for the random variable samples (default: None). + pdf_plot : tuple, optional + A tuple containing the x and y values for a PDF plot to overlay on the predictive plot (default: None). + pdf_label : str, optional + The label for the PDF plot (default: "PDF"). + colour_scale : str, optional + The colour scale to use for the predictive traces (default: "viridis"). + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created for each problem. + Can be a single figure or one figure per problem. + axes: axis, optional + The axes to be used for plotting. One axis per problem is expected. + plotly: axes expected to be of the form list of tuple(row, col) + + Returns + ------- + None: if show is True + Figure or list of figures: if show is False + If show is False, returns a single figure or a list of figures containing the predictive posterior + for each problem. """ - # Import plotly only when needed - px = PlotlyManager().px + # Create a plot for each problem + problems = ( + result.problem.problems + if isinstance(result.problem, MetaProblem) + else [result.problem] + ) + + # Import plotting backend + backend = get_backend_from_figure(backend, figures) + + # Process input figures + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=len(problems), allow_single_axis=False + ) + + # Retrieve data for plotting posterior_samples = result.posterior.sample_from_distribution( n_samples=number_of_traces ) @@ -37,23 +86,28 @@ def predictive( ) pdf_range = np.asarray([posterior_samples_pdf.min(), posterior_samples_pdf.max()]) - # Create a plot for each problem - problems = ( - result.problem.problems - if isinstance(result.problem, MetaProblem) - else [result.problem] - ) - figure_list = [] - - 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]), + for i, problem in enumerate(problems): + if create_figure: + fig = backend.create_figure( + style={"bg_color": "white", "width": 600, "height": 600}, + ) + figures = np.append(figures, fig) + ax = None + else: + fig = figures[i] + ax = axes[i] + + backend.update_axes_titles( + fig, ax, remove_brackets(problem.domain), remove_brackets(problem.target[0]) + ) + backend.plot_trace( + backend.line( + x=problem.domain_data, + y=problem.target_data[problem.target[0]], + label=data_legend_entry, ), - trace_names=data_legend_entry, + fig, + ax=ax, ) # Simulate the samples and add to plot @@ -61,44 +115,36 @@ 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.sample_color_scale( + pdf, d_min=pdf_range[0], d_max=pdf_range[1] + ) + backend.plot_trace( + backend.line( + x=problem.domain_data, + y=sim[problem.target[0]].data, + style=dict(color=colors[0], linestyle="dotted"), + ), + fig, + ax=ax, ) # 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.colorbar( + fig, pdf_range, colorscale=colour_scale, label="Posterior PDF", ax=ax ) if pdf_plot is not None: - plot_dict.add_traces( - x=pdf_plot[0], - y=pdf_plot[1], - trace_names=pdf_label, + backend.plot_trace( + backend.line( + x=pdf_plot[0], + y=pdf_plot[1], + label=pdf_label, + ), + fig, + ax=ax, ) - - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) if show: - fig.show() - - figure_list.append(fig) + backend.show_figure(fig) - return figure_list + if not show: + return figures[0] if len(figures) == 1 else figures diff --git a/pybop/plot/problem.py b/pybop/plot/problem.py index df1d6703d..61adb604e 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_from_figure, remove_brackets from pybop.problems.meta_problem import MetaProblem from pybop.problems.problem import Problem from pybop.simulators.solution import Solution @@ -12,8 +12,11 @@ def problem( problem: Problem, inputs: Inputs = None, + title="Scatter Plot", show: bool = True, - **layout_kwargs, + backend: str = None, + figures=None, + axes=None, ): """ Produce a quick plot of the target dataset against optimised model output. @@ -27,17 +30,25 @@ 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created for each problem. + Can be a single figure or one figure per target. + axes: axis, optional + The axes to be used for plotting. One axis per target is expected. + plotly: axes expected to be of the form list of tuple(row, col) Returns ------- - plotly.graph_objs.Figure - The Plotly figure object for the scatter plot. + None: if show is True + Figure or list of figures: if show is False + A single figure or a list of figures containing the plots for each target in the + problem. If show is True, the figures will be displayed and None will be returned. """ if inputs is None: inputs = problem.parameters.to_dict() @@ -67,66 +78,73 @@ def problem( model_domain = target_domain[: len(model_output[target].data)] # Create a plot for each output - 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), + # Import plotting backend + backend = get_backend_from_figure(backend, figures) + + # Process input + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=len(problem.target), allow_single_axis=False + ) + for i, var in enumerate(problem.target): + ax = axes[i % len(axes)] + if create_figure: + fig = backend.create_figure( + style={"bg_color": "white", "width": 600, "height": 600}, ) + figures = np.append(figures, fig) + else: + fig = figures[i % len(figures)] + + backend.update_axes_titles( + fig, ax, remove_brackets(domain), remove_brackets(var) ) + backend.update_plot_titles(fig, ax, title) + traces = [] - model_trace = plot_dict.create_trace( + model_trace = backend.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.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, - ) - plot_dict.traces.append(fill_trace) + y_upper = (model_output[var].data + sigma).tolist() + y_lower = (model_output[var].data - sigma).tolist() + + fill_trace = backend.fill_between(x, y_upper, y_lower, color="#FFE5CC") + 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.plot_trace(trace, fig, ax=ax) + + backend.legend(fig, axes=ax) # Generate the figure and update the layout - fig = plot_dict(show=False) - fig.update_layout(**layout_kwargs) if show: - fig.show() - - figure_list.append(fig) + backend.show_figure(fig) - return figure_list + if not show: + return figures[0] if len(figures) == 1 else figures diff --git a/pybop/plot/samples.py b/pybop/plot/samples.py index 55ee77cd0..331bf83c9 100644 --- a/pybop/plot/samples.py +++ b/pybop/plot/samples.py @@ -1,109 +1,227 @@ from typing import TYPE_CHECKING -from pybop.plot import PlotlyManager +import numpy as np + +from pybop.plot.util import get_backend_from_figure if TYPE_CHECKING: from pybop.samplers.base_pints_sampler import SamplingResult -def trace(result: "SamplingResult", **kwargs): +def chains(result: "SamplingResult", show=True, backend=None, figures=None, axes=None): """ - Plot trace plots for the posterior samples. + Plot posterior distributions for each chain. + + Parameters + ---------- + result : pybop.SamplingResult + The result of the sampling process. + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + axes: axis, optional + The axes to be used for plotting. A single axis is expected. + + Returns + ------- + None: if show is True + Figure object: if show is False + The figure object for the chain plots. """ - # Import plotly only when needed - go = PlotlyManager().go + # Import backend + backend = get_backend_from_figure(backend, figures) + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=1 + ) + fig = backend.create_figure() if create_figure else figures[0] + ax = axes[0] - for i in range(result.n_parameters): - fig = go.Figure() + backend.update_axes_titles(fig, ax, "Value", "Density") + backend.update_plot_titles(fig, ax, "Posterior Distribution") + parameter_names = result.problem.parameters.names + for i, chain in enumerate(result.chains): + for j in range(chain.shape[1]): + backend.plot_trace( + backend.histogram_plot( + x=chain[:, j], + name=f"Chain {i} - {parameter_names[j]}", + style=dict(alpha=0.75), + ), + fig, + ax=ax, + ) - for j, chain in enumerate(result.chains): - fig.add_trace(go.Scatter(y=chain[:, i], mode="lines", name=f"Chain {j}")) + backend.vline( + fig, + result.mean[j], + style=dict(linewidth=1, linestyle="dashed", color="black"), + ax=ax, + ) - fig.update_layout( - title=f"Parameter {i} Trace Plot", - xaxis_title="Sample Index", - yaxis_title="Value", - ) - fig.update_layout(**kwargs) - fig.show() + backend.legend(fig) + + if show: + backend.show_figure(fig) + else: + return fig -def chains(result: "SamplingResult", **kwargs): +def trace(result: "SamplingResult", show=True, backend=None, figures=None, axes=None): """ - Plot posterior distributions for each chain. + Plot trace plots for the posterior samples. + + Parameters + ---------- + result : pybop.SamplingResult + The result of the sampling process. + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + Can be a single figure or one figure per parameter. + axes: single axis or list of axes, optional + axes for plotting + plotly: axes expected to be of the form tuple(row, col) + Number of axis must either agree with the number of parameters or + be a single axis for all parameters. + + Returns + ------- + None: if show is True + Figure or list of figures: if show is False + If show is False, returns a single figure or a list of figures containing the trace plots + for each parameter. """ - # Import plotly only when needed - go = PlotlyManager().go + # Import plotting backend + backend = get_backend_from_figure(backend, figures) - fig = go.Figure() + # Process input + figures, axes, create_figure, single_axis = backend.parse_input_axes( + figures, axes, num_plots=result.n_parameters + ) - for i, chain in enumerate(result.chains): - for j in range(chain.shape[1]): - fig.add_trace( - go.Histogram( - x=chain[:, j], - name=f"Chain {i} - Parameter {j}", - opacity=0.75, - ) - ) + parameter_names = result.problem.parameters.names + for i in range(result.n_parameters): + ax = axes[i % len(axes)] + title = ( + "Parameter Trace Plot" + if single_axis + else f"Trace Plot - {parameter_names[i]}" + ) + if create_figure: + fig = backend.create_figure() + figures = np.append(figures, 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"), - ) + fig = figures[i % len(figures)] + backend.update_axes_titles(fig, ax, "Sample Index", "Value") + if i == 0 or not single_axis: + backend.update_plot_titles(fig, ax, title) - fig.update_layout( - barmode="overlay", - title="Posterior Distribution", - xaxis_title="Value", - yaxis_title="Density", - ) - fig.update_layout(**kwargs) - fig.show() + for j, chain in enumerate(result.chains): + label = f"{parameter_names[i]} - Chain {j}" if single_axis else f"Chain {j}" + backend.plot_trace(backend.line(y=chain[:, i], label=label), fig, ax=ax) + backend.legend(fig) + if show: + backend.show_figure(figures) + else: + return figures[0] if len(figures) == 1 else figures -def posterior(result: "SamplingResult", **kwargs): + +def posterior( + result: "SamplingResult", backend=None, show=True, figures=None, axes=None +): """ Plot the summed posterior distribution across chains. + + Parameters + ---------- + result : pybop.SamplingResult + The result of the sampling process. + show : bool, optional + If True, the figure is shown upon creation (default: True). + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + axes: axis, optional + The axes to be used for plotting. A single axis is expected. + + Returns + ------- + None: if show is True + Figure object: if show is False + The figure object for the posterior distribution plot. """ - # Import plotly only when needed - go = PlotlyManager().go + # Import backend + backend = get_backend_from_figure(backend, figures) - fig = go.Figure() + # Parse input + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=1 + ) + fig = backend.create_figure() if create_figure else figures[0] + ax = axes[0] + + backend.update_axes_titles(fig, ax, "Value", "Density") + backend.update_plot_titles(fig, ax, "Posterior Distribution") + parameter_names = result.problem.parameters.names for j in range(result.all_samples.shape[1]): - histogram = go.Histogram( - x=result.all_samples[:, j], - name=f"Parameter {j}", - opacity=0.75, + backend.plot_trace( + backend.histogram_plot( + x=result.all_samples[:, j], + name=f"{parameter_names[j]}", + style=dict(alpha=0.75), + ), + fig, + ax=ax, ) - fig.add_trace(histogram) - fig.add_vline( - x=result.mean[j], line_width=3, line_dash="dash", line_color="black" + backend.vline( + fig, + result.mean[j], + style=dict(linewidth=1, linestyle="dashed", color="black"), + ax=ax, ) - fig.update_layout( - barmode="overlay", - title="Posterior Distribution", - xaxis_title="Value", - yaxis_title="Density", - ) - fig.update_layout(**kwargs) - fig.show() - return fig + backend.legend(fig) + if show: + backend.show_figure(fig) + else: + return fig -def summary_table(result: "SamplingResult"): + +def summary_table( + result: "SamplingResult", backend=None, figures=None, axes=None, show=True +): """ Display summary statistics in a table. + + Parameters + ---------- + result : pybop.SamplingResult + The result of the sampling process. + backend : str or pybop.plot.backends.PlotBackend, optional + Select a plotting backend. If None, the current default backend is used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + axes: axis, optional + The axes to be used for plotting. A single axis is expected. + show : bool, optional + If True, the figure is shown upon creation (default: True). + + Returns + ------- + None: if show is True + Figure object: if show is False + The figure object for the summary statistics table. """ - # Import plotly only when needed - go = PlotlyManager().go summary_stats = result.get_summary_statistics() @@ -116,16 +234,20 @@ 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_from_figure(backend, figures) + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=1 ) - - fig.update_layout(title="Summary Statistics") - fig.show() + fig = None if create_figure else figures[0] + ax = axes[0] + fig = backend.show_table( + header=header, + values=values, + title="Summary Statistics", + fig=fig, + ax=ax, + ) + if show: + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/standard_plots.py b/pybop/plot/standard_plots.py deleted file mode 100644 index 962f9a5f8..000000000 --- a/pybop/plot/standard_plots.py +++ /dev/null @@ -1,386 +0,0 @@ -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") - - -class StandardPlot: - """ - A class for creating and displaying interactive Plotly figures. - - 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. - 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 - 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). - - Returns - ------- - plotly.graph_objs.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, - ): - 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 - - # 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) - - def __call__(self, show=True): - """ - Generate and show the figure. - - Parameters - ---------- - 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() - - return fig - - def add_traces(self, x, y, trace_names=None, **trace_options): - """ - Add a set of traces to the plot dictionary. - - Parameters - ---------- - x : list or np.ndarray - 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). - """ - 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 - 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. - - 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 - - -class StandardSubplot(StandardPlot): - """ - A class for creating and displaying a set of interactive Plotly figures in a grid layout. - - Parameters - ---------- - x : list or np.ndarray - X-axis data points. - y : list or np.ndarray - Primary Y-axis data points for simulated model output. - num_rows : int, optional - 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 - 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). - - Returns - ------- - plotly.graph_objs.Figure - The generated Plotly 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, - ): - super().__init__( - x, y, layout, layout_options, trace_options, trace_names, trace_name_width - ) - self.num_traces = len(self.traces) - 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)) - elif self.num_rows is None: - self.num_rows = int(math.ceil(self.num_traces / 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 - - # Attempt to import plotly when an instance is created - self.make_subplots = PlotlyManager().make_subplots - - def __call__(self, show): - """ - Generate and show the set of figures. - - Parameters - ---------- - 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.update_layout(self.layout_options) - - for idx, trace in enumerate(self.traces): - 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", - ) - - 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 diff --git a/pybop/plot/trajectories.py b/pybop/plot/trajectories.py new file mode 100644 index 000000000..6af5826b9 --- /dev/null +++ b/pybop/plot/trajectories.py @@ -0,0 +1,83 @@ +from pybop.plot.util import get_backend_from_figure, parse_data, wrap_text + + +def trajectories( + x, + y, + title: str = None, + xaxis_title: str = None, + yaxis_title: str = None, + labels=None, + label_width=20, + show=True, + backend=None, + figures=None, + axes=None, +): + """ + Quickly plot one or more trajectories. + + 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created + axes: axis, optional + Thes axis to be used for plotting + plotly: axis expected to be of the form tuple(row, col) + + Returns + ------- + fig : if show is False; plotly.graph_objs.Figure or matplotlib.figure.Figure + None : if show is True + """ + backend = get_backend_from_figure(backend, figures) + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=1 + ) + + if create_figure: + fig = backend.create_figure( + style={"height": 600, "width": 600, "bg_color": "white"} + ) + else: + fig = figures[0] + + backend.update_axes_titles(fig, axes[0], xaxis_title, yaxis_title) + backend.update_plot_titles(fig, axes[0], title) + + x, y = parse_data(x, y) + xi = x[0] + for i in range(0, len(y)): + if len(x) > 1: + xi = x[i] + label = None + if labels is not None: + label = wrap_text(labels[i], label_width, backend=backend.name) + + backend.plot_trace(backend.line(xi, y[i], label), fig, ax=axes[0]) + + backend.legend(fig, axes=axes[0]) + + if show: + backend.show_figure(fig) + else: + return fig diff --git a/pybop/plot/util.py b/pybop/plot/util.py new file mode 100644 index 000000000..2a09aeedf --- /dev/null +++ b/pybop/plot/util.py @@ -0,0 +1,186 @@ +import textwrap +import warnings + +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 or pybop.plot.backends.PlotBackend + 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"] or isinstance( + backend, pybop.plot.backends.PlotBackend + ): + 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used (default: pybop.plot.current_backend) + """ + if backend is None: + backend = pybop.plot.current_backend + elif isinstance(backend, pybop.plot.backends.PlotBackend): + return 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) + + +def get_backend_from_figure(backend=None, figures=None): + """ + Get instance of PlotBackend class from a provided figure or from a specified backend. + If both are provided, the figure's backend takes precedence. + + Parameters + ---------- + backend : str or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used (default: pybop.plot.current_backend) + figures: figure object, optional + Figure for plotting. If not provided a new figure is created + + Returns + ------- + pybop.plot.backends.PlotBackend + Instance of the selected plotting backend. + """ + + if figures is not None and len(np.atleast_1d(figures)) > 0: + # Determine the backend from the provided figure + if hasattr(figures, "__len__"): + figures = figures[0] + if "matplotlib" in str(type(figures)).lower(): + figure_backend = "matplotlib" + elif "plotly" in str(type(figures)).lower(): + figure_backend = "plotly" + else: + raise ValueError( + f"Could not determine the backend from the provided figure of type {type(figures)}" + ) + # If a backend is provided, check if it matches the figure's backend + if backend is not None: + backend_str = ( + backend.name + if isinstance(backend, pybop.plot.backends.PlotBackend) + else backend.lower() + ) + + if backend_str != figure_backend: + warnings.warn( + f"Backend {backend} does not match the provided figure's backend {figure_backend}. " + "Using the figure's backend.", + UserWarning, + stacklevel=2, + ) + # Use the figure's backend if they don't match + backend = figure_backend + else: + # If no backend is provided, use the figure's backend + backend = figure_backend + + return get_backend(backend) + + +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 3a78f8c60..080b95699 100644 --- a/pybop/plot/voronoi.py +++ b/pybop/plot/voronoi.py @@ -3,9 +3,10 @@ import numpy as np from scipy.spatial import Voronoi, cKDTree +from pybop.plot.util import get_backend_from_figure + if TYPE_CHECKING: from pybop._result import Result -from pybop.plot.plotly_manager import PlotlyManager def _voronoi_regions(x, y, f, xlim, ylim): @@ -228,11 +229,14 @@ def assign_nearest_value(x, y, f, xi, yi): def surface( result: "Result", + title="Voronoi Cost Landscape", bounds=None, normalise=True, resolution=250, show=True, - **layout_kwargs, + backend=None, + figures=None, + axes=None, ): """ Plot a 2D representation of the Voronoi diagram with color-coded regions. @@ -241,6 +245,8 @@ 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`. @@ -251,11 +257,20 @@ def surface( 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 or pybop.plot.backends.PlotBackend, optional + The plotting backend to be used. + figures: figure object, optional + Figure for plotting. If not provided a new figure is created. + axes: axis, optional + The axes to be used for plotting. A single axis is expected. + + Returns + ------- + None: if show is True + Figure object: if show is False + """ + backend = get_backend_from_figure(backend, figures) points = result.x_model parameters = result.problem.parameters @@ -319,106 +334,112 @@ def surface( ) # 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 + num_plots = 1 + figures, axes, create_figure, _ = backend.parse_input_axes( + figures, axes, num_plots=num_plots + ) + if create_figure: + fig = backend.create_figure( + style={ + "width": 600, + "height": 600, + }, ) + ax = None + else: + fig = figures[0] + ax = axes[0] + + backend.update_axes_titles(fig, ax, names[0], names[1]) + backend.update_plot_titles(fig, ax, title, pad=30) + backend.update_axes_ranges(fig, ax, xlim, ylim) + + backend.plot_trace( + backend.heatmap(xi[0], yi[:, 0], zi, colorscale="viridis"), fig, ax=ax ) + backend.colorbar(fig, f, ax=ax) # Add Voronoi edges for region, size in zip(regions, relative_sizes, strict=False): 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.line( + x_region, + y_region, + style=dict(color="white", linewidth=0.5 + size * 0.1), + ), + fig, + ax=ax, ) # 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, + ax=ax, ) # 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, + ax=ax, ) # 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, + ax=ax, ) - 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 left", + "coords": (0, 1), + }, + axes=ax, ) - fig.update_layout(**layout_kwargs) + if show: - fig.show() + backend.show_figure(fig) + else: + return fig diff --git a/pyproject.toml b/pyproject.toml index a5952e74a..07ebf11ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ # versions in the tests in nightly_dependency_tests.yml as appropriate requires-python = ">=3.10, <3.15" dependencies = [ - "pybamm>=26.3.0", + "pybamm[plot]>=26.3.0", "numpy>=1.26", "scipy>=1.12", "pints>=0.6.0", diff --git a/tests/plotting/test_plotly_manager.py b/tests/plotting/test_plotly_manager.py index 7050c1425..9a88c011e 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") @@ -129,21 +129,9 @@ def dataset(plotly_installed): @pytest.mark.unit -def test_standard_plot(dataset, plotly_installed): - # Check the StandardPlot class - pybop.plot.StandardPlot(dataset["Time [s]"], dataset["Voltage [V]"]) - - # Check the StandardSubplot class - pybop.plot.StandardSubplot( - dataset["Time [s]"], - [dataset["Voltage [V]"], dataset["Current [A]"]], - num_rows=1, - ) - pybop.plot.StandardSubplot( - dataset["Time [s]"], - [dataset["Voltage [V]"], dataset["Current [A]"]], - num_cols=1, - ) +def test_trajectories_plot(dataset, plotly_installed): + # Set plotting backend + pybop.plot.use_backend("plotly") # Check plot numpy arrays, lists, and lists of numpy arrays pybop.plot.trajectories(dataset["Time [s]"], dataset["Voltage [V]"]) @@ -169,6 +157,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..196544ba4 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -1,3 +1,4 @@ +import matplotlib.pyplot as plt import numpy as np import pybamm import pytest @@ -6,6 +7,7 @@ import pybop +@pytest.mark.parametrize("backend", ["plotly", "matplotlib"]) class TestPlots: """ A class to test the plot classes. @@ -13,17 +15,16 @@ class TestPlots: pytestmark = pytest.mark.unit - def test_standard_plot(self): - # Test standard plot - trace_names = pybop.plot.StandardPlot.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, - ) - plot_dict() + @pytest.fixture + def figure_input(self, backend): + if backend.lower() == "matplotlib": + return plt.figure(), plt.gca() + elif backend.lower() == "plotly": + fig = pybop.plot.backends.PlotlyManager().make_subplots( + rows=2, cols=1, specs=[[{}], [{"type": "table"}]] + ) + ax = (1, 1) + return fig, ax @pytest.fixture def model(self): @@ -69,15 +70,22 @@ 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, figure_input): + pybop.plot.use_backend(backend) + fig, ax = figure_input # 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) + fig = pybop.plot.dataset( + dataset, signal=["Voltage [V]"], figures=fig, axes=[ax], show=False + ) + assert fig is not None + @pytest.fixture def fitting_problem(self, model, parameters, dataset): parameter_values = model.default_parameter_values @@ -112,30 +120,55 @@ 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, figure_input + ): + pybop.plot.use_backend(backend) + fig, ax = figure_input # Test plot of Problem objects - pybop.plot.problem(fitting_problem, title="Optimised Comparison") - pybop.plot.problem(design_problem) + pybop.plot.problem( + fitting_problem, title="Optimised Comparison", figures=fig, axes=[ax] + ) + fig = pybop.plot.problem(design_problem, show=False) + assert fig is not None # Test conversion of values into inputs pybop.plot.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, figure_input + ): + pybop.plot.use_backend(backend) # Test plot of Cost objects - pybop.plot.contour(fitting_problem, gradient=True, steps=5) + fig, ax = figure_input - pybop.plot.contour(fitting_problem, gradient=True, steps=5, transformed=True) + pybop.plot.contour( + fitting_problem, gradient=True, steps=5, figures=fig, axes=[ax] * 3 + ) + + pybop.plot.contour( + fitting_problem, + gradient=True, + steps=5, + transformed=True, + figures=[fig], + axes=[ax] * 3, + ) # Test without bounds with pytest.raises(ValueError, match="All parameters require bounds for plot."): pybop.plot.contour(fitting_problem_no_bounds, steps=5) - # Test with bounds - pybop.plot.contour( - fitting_problem, bounds=np.array([[0.5, 0.8], [0.4, 0.7]]), steps=5 + # Test with bounds and show=False + fig = pybop.plot.contour( + fitting_problem, + bounds=np.array([[0.5, 0.8], [0.4, 0.7]]), + steps=5, + show=False, ) + assert fig is not None @pytest.fixture def result(self, fitting_problem): @@ -143,14 +176,17 @@ 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, figure_input): + pybop.plot.use_backend(backend) bounds = np.asarray([[0.5, 0.8], [0.4, 0.7]]) + fig, ax = figure_input # Plot convergence - result.plot_convergence() + result.plot_convergence(figures=fig, axes=[ax]) # Plot the parameter traces - result.plot_parameters() + fig2 = result.plot_parameters(show=False) + assert fig2 is not None # Plot the cost landscape with optimisation path result.plot_contour(steps=3) @@ -159,19 +195,28 @@ def test_optim_plots(self, result): result.plot_contour(steps=3, bounds=bounds) # Plot gradient cost landscape - result.plot_contour(gradient=True, steps=5) + fig2, grad_figs = result.plot_contour(gradient=True, steps=5, show=False) + assert fig2 is not None + assert len(grad_figs) == len(result.problem.parameters) # Plot voronoi - result.plot_surface(normalise=False) + fig2 = result.plot_surface(normalise=False, show=False) + assert fig2 is not None # Plot voronoi w/ bounds - result.plot_surface(bounds=bounds) + result.plot_surface(bounds=bounds, figures=fig, axes=[ax]) with pytest.raises( ValueError, match="Lower bounds must be strictly less than upper bounds." ): result.plot_surface(bounds=[[0.5, 0.8], [0.7, 0.4]]) + with pytest.raises( + ValueError, match="This plot method requires two parameters." + ): + result._x_model = [np.ones((np.shape(result._x_model)[1], 1))] + result.plot_surface() + @pytest.fixture def sampling_result(self, model, parameters, dataset): parameter_values = model.default_parameter_values @@ -179,46 +224,74 @@ def sampling_result(self, model, parameters, dataset): simulator = pybop.pybamm.Simulator( model, parameter_values=parameter_values, protocol=dataset ) - log_likelihood = pybop.GaussianLogLikelihoodKnownSigma(dataset, sigma=2e-3) + log_likelihood = pybop.GaussianLogLikelihood(dataset) log_pdf = pybop.LogPosterior(simulator, log_likelihood) options = pybop.PintsSamplerOptions(n_chains=1, max_iterations=2) 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, figure_input): + pybop.plot.use_backend(backend) + fig, ax = figure_input sampling_result.get_summary_statistics() # Plot trace - sampling_result.plot_trace() + f = sampling_result.plot_trace(show=False) + assert f is not None + sampling_result.plot_trace(figures=fig, axes=[ax]) # Plot posterior - sampling_result.plot_posterior() + f = sampling_result.plot_posterior(show=False) + assert f is not None + sampling_result.plot_posterior(figures=fig, axes=[ax]) # Plot chains - sampling_result.plot_chains() - - # Plot summary table - sampling_result.summary_table() + f = sampling_result.plot_chains(show=False) + assert f is not None + sampling_result.plot_chains(figures=fig, axes=[ax]) # Plot posterior predictions - sampling_result.plot_predictive() + f = sampling_result.plot_predictive(show=False, pdf_plot=[[1, 2], [2, 3]]) + assert f is not None + sampling_result.plot_predictive(figures=fig, axes=[ax]) # Plot the prior and posterior distributions + f = pybop.plot.distribution( + sampling_result.problem.parameters, sampling_result.posterior, show=False + ) + assert f is not None pybop.plot.distribution( - sampling_result.problem.parameters, sampling_result.posterior + sampling_result.problem.parameters, + sampling_result.posterior, + figures=fig, + axes=[ax], ) - def test_with_ipykernel(self, dataset, fitting_problem, result): + # Plot summary table + f = sampling_result.summary_table(show=False) + assert f is not None + if backend.lower() == "plotly": + ax = (2, 1) # Need correct plot type for table + sampling_result.summary_table(figures=fig, axes=[ax]) + + 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) + fig = result.plot_convergence(show=False) + assert fig is not None + backend = pybop.plot.get_backend(backend) + backend.show_figure(fig) result.plot_convergence() 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 @@ -257,9 +330,11 @@ def test_contour_incorrect_number_of_parameters(self, model, dataset): ) cost = pybop.SumSquaredError(dataset) fitting_problem = pybop.Problem(simulator, cost) - pybop.plot.contour(fitting_problem) + with pytest.warns(UserWarning, match="more than 2 parameters"): + pybop.plot.contour(fitting_problem) - def test_nyquist(self): + def test_nyquist(self, backend, figure_input): + pybop.plot.use_backend(backend) # Define model model = pybamm.lithium_ion.SPM(options={"surface form": "differential"}) parameter_values = model.default_parameter_values @@ -295,8 +370,191 @@ def test_nyquist(self): problem = pybop.Problem(simulator, cost) # Plot the nyquist + fig, ax = figure_input inputs = problem.parameters.to_dict([60e-6]) - pybop.plot.nyquist(problem, inputs=inputs, title="Optimised Comparison") + pybop.plot.nyquist( + problem, inputs=inputs, title="Optimised Comparison", figures=fig, axes=[ax] + ) # Without inputs - pybop.plot.nyquist(problem, title="Optimised Comparison") + fig = pybop.plot.nyquist(problem, title="Optimised Comparison", show=False) + assert fig is not None + + def test_util(self, backend, figure_input): + # Test the utility functions + pybop.plot.use_backend(backend) + assert pybop.plot.remove_brackets(["Trace [1]", "Trace [2]"])[0] == "Trace / 1" + assert pybop.plot.remove_brackets(10) == 10 + + x, y = pybop.plot.parse_data(np.zeros((3, 20)), np.zeros((3, 20))) + assert ( + isinstance(x, list) and isinstance(y, list) and len(x) == 3 and len(y) == 3 + ) + + if backend.lower() == "matplotlib": + backend_inputs = [ + "matplotlib", + "MaTpLoTliB", + pybop.plot.backends.MatplotlibBackend(), + None, + ] + figures_inputs = [None, [], [plt.figure()]] + for backend_input in backend_inputs: + for figures_input in figures_inputs: + backend_return = pybop.plot.get_backend_from_figure( + backend_input, figures_input + ) + assert isinstance( + backend_return, pybop.plot.backends.MatplotlibBackend + ) + + if backend.lower() == "plotly": + backend_inputs = [ + "plotly", + "PlOtLy", + pybop.plot.backends.PlotlyBackend(), + None, + ] + go = pybop.plot.backends.PlotlyManager().go + figures_inputs = [None, [], [go.Figure()]] + for backend_input in backend_inputs: + for figures_input in figures_inputs: + backend_return = pybop.plot.get_backend_from_figure( + backend_input, figures_input + ) + assert isinstance(backend_return, pybop.plot.backends.PlotlyBackend) + + # Assert error is raised for unsupported backend + with pytest.raises( + ModuleNotFoundError, match="Plotting backend nonsense is not available." + ): + backend = pybop.plot.get_backend_from_figure("nonsense", None) + + # Error if figure is not a plotly or matplotlib figure + with pytest.raises( + ValueError, + match="Could not determine the backend from the provided figure of type ", + ): + backend = pybop.plot.get_backend_from_figure(None, [object()]) + + # Assert current default used if backend is nonsensical + with pytest.raises( + ModuleNotFoundError, + match="Plotting backend nonsense is not available. The current backend has not been updated. \n" + f"The current backend is set to {backend}", + ): + pybop.plot.use_backend("nonsense") + + # Use figure's backend if backend is different from the figure's backend + with pytest.warns( + UserWarning, + match="Backend wrong backend does not match the provided figure's backend", + ): + fig = figure_input[0] + pybop.plot.get_backend_from_figure("wrong backend", [fig]) + + def test_backend(self, backend): + backend = pybop.plot.get_backend(backend) + traces = [] + for i in range(3): + traces.append( + backend.line( + [1, 2], + [1 + i, 2 + i], + style=dict( + linestyle="solid", + marker="o", + xaxis_title=f"X-axis {i}", + yaxis_title=f"Y-axis {i}", + ), + ) + ) + fig = backend.create_figure( + title="Test Figure", + xaxis_title="X-axis", + yaxis_title="Y-axis", + traces=traces, + ) + + # Test error handling for invalid inputs for parse_input_axes + with pytest.raises( + ValueError, match="This plot requires 5 axes. 2 axes provided." + ): + axes = ( + [fig.gca(), fig.gca()] + if backend.name == "matplotlib" + else [(1, 1), (1, 2)] + ) + backend.parse_input_axes(fig, axes, num_plots=5, allow_single_axis=False) + + with pytest.raises( + ValueError, + match="This plot requires either 5 axes or a single axis. 2 axes provided.", + ): + axes = ( + [fig.gca(), fig.gca()] + if backend.name == "matplotlib" + else [(1, 1), (1, 2)] + ) + backend.parse_input_axes(fig, axes, num_plots=5, allow_single_axis=True) + + with pytest.raises( + ValueError, + match="Please provide the same number of figures and axes or only one figure.", + ): + backend.parse_input_axes( + [fig, fig], + [fig.gca() if backend.name == "matplotlib" else (1, 1)], + num_plots=2, + allow_single_axis=False, + ) + + with pytest.warns( + UserWarning, match="Axes argument ignored if no figure provided." + ): + backend.parse_input_axes( + None, + [fig.gca() if backend.name == "matplotlib" else (1, 1)], + num_plots=1, + allow_single_axis=False, + ) + + if backend.name == "plotly": + with pytest.raises(ValueError, match="Axis must be a tuple"): + backend.parse_input_axes(fig, (1, 3, 4)) + + # Axes from figures + figures, axes, create_figure, single_axis = backend.parse_input_axes( + [fig, fig], None, num_plots=2, allow_single_axis=False + ) + assert ( + len(figures) == 2 + and len(axes) == 2 + and create_figure is False + and single_axis is False + ) + + # loc property for legend + with pytest.raises(ValueError, match="loc property must consist of 2 keywords"): + backend.legend(fig, style=dict(loc="upper")) + + # subplots with not enough grid space + with pytest.raises(ValueError, match="Insufficient subplots"): + backend.make_subplots(num_rows=1, num_cols=2, num_plots=5) + + # try plotting line without y data + with pytest.raises(ValueError, match="y must be provided"): + backend.line() + + # Some legend options + backend.legend(fig, style=dict(outside=("right", 0.1))) + backend.legend(fig, style=dict(outside=("left", 0.1))) + backend.legend(fig, style=dict(outside=("top", 0.1))) + backend.legend(fig, style=dict(outside=("bottom", 0.1))) + + # Some line styling + backend.line( + x=[1, 2], + y=[1, 2], + style=dict(linestyle="solid", marker="o", markeredgewidth=2.0), + )