plotnik
is a Python library designed for creating simple graphs using
matplotlib in Cartesian coordinates, mirroring the style of 'school' graphs
traditionally used in Russian physics and mathematics education. It was
developed for convenient drawing of thermodynamic cycles, including nonlinear
processes, without need to perform calculations.
The library is currently usable. The code is poorly designed. Full documentation is not available, but you can refer to the examples provided below to understand its functionality.
The library utilizes syntax inspired by the SchemDraw library.
The code has been mostly written by Chat-GPT.
The default font is 'STIX Two Text'. To switch to Computer Modern Roman, use
d.set_config(font='serif')
, noting that this requires LaTeX to be installed
on your machine.
To draw the curves, use processes
: class Process()
with its subclasses:
-
Linear()
Draw a straight line from .at() to .to(). -
Power()
Connects initial and final points with the equation y=k*x^n + b -
Adiabatic()
Draw adiabatic process pV^gamma = const for p(V) coordinates. Set the gamma value usingAdiabatic(gamma=7/5)
, with the default being 5/3. -
Iso_t()
Isothermal process pV=const in p(V) coordinates. -
Bezier(x,y)
Draw quadratic or cubic Bezier curve.d += Bezier(x=2,y=2).at(1,1).to(3,1)
draws quadratic Bezier curve from (1,1) to (3,1) with a single control point at (2,2). Similarly,
d += Bezier(x1=3,y1=7, x2=5,y2=3).at(1,5).to(7,5)
this code plots a cubic Bezier curve, resembling a sine wave, with two control points at (x1, y1) and (x2, y2). Note that
d +=
is usually optional.
Additionally, standard matplotlib syntax can be used to add text and lines to
the plot, for example, d.ax.plot(x, y)
.
This example illustrates well the purpose behind the creation of the library. It is necessary to draw a cycle in pV-coordinates, consisting of an isochore, adiabat, and isotherm. The goal was to free the user from the need to perform calculations and to provide a simple interface for constructing such graphs.
from plotnik import *
v1 = 3
v2 = 9
v3 = v1
p1 = 9
with Drawing() as d:
d.set_config(
xname='$V$',
yname='$p$',
zero_x=0.5,
axes_arrow_width=0.23,
)
A1 = Adiabatic().at(v1,p1).to(v2, 'volume').arrow().dot()
p2 = A1.end[1] # A1.end returns coordinates (x,y) for the last point of A1 process
# Process T1 has no .at(), so it takes the last point from the previous
# process A1 as an initial point
T1 = Iso_t().to(v1, 'volume').arrow().dot().label(2, dy=0)
p3 = T1.end[1]
Linear().to(v1,p1).arrow().dot().label(3,1)
d.show()
# `crop=True` is only compatible with SVG files and requires the installation of `Inkscape` on your machine.
# This feature removes paths named `patch_1` and `patch_2`, which, in my case, do not contain any paths
# but add a whitespace margin.
d.save('filename.svg', crop=True)
from plotnik import *
u1 = 2
u2 = 4
v1 = 3
v2 = 6
with Drawing() as d:
d.set_config(
yname='$U$',
xname='$V$',
zero_x=0.4,
ylim=[0,6],
xlim=[0,8],
)
Linear().at(v1,u1).to(v2,u2).arrow().dot('both').label(1,2)
d.grid(y_end=5)
d.show()
from plotnik import *
p1 = 10
v1 = 3
v2 = 6
v3 = 10
with Drawing() as d:
d.set_config(
fontsize=30,
yname='$p$',
xname='$V$',
aspect=0.7,
xlim=[0,11],
center=[5.5, 4.5],
)
# When the second argument in the .to() method is 'volume', the function draws a line up to
# volume v2 and calculates the required pressure. To retrieve this pressure value, use end_p(process)
T1 = Iso_t().at(v1, p1).to(v2, 'volume').dot('both').label(1,2)
p2 = T1.end[1]
A1 = Adiabatic().to(v3, 'volume')
p3 = A1.end[1]
# common_pv calculates the volume (v) and pressure (p) at the intersection of an isothermal process
# passing through the start point and an adiabatic process passing through the end point.
v4, p4 = common_pv(v1,p1, v3,p3)
Iso_t().to(v4, 'volume').dot('both').label(3,4)
Adiabatic().to(v1,'volume')
Power(15).at(v2, p2).to(v4,p4)
d.ax.text(4.75, 4.8, '$A_1$', fontsize=24)
d.ax.text(5.65, 3.9, '$A_2$', fontsize=24)
d.show()
from plotnik import *
with Drawing() as d:
d.set_config(yname=r'$x$',
xname=r'$t$',
xlim=[0,12],
center_x=5,
)
B = Bezier(x1=5,y1=15, x2=6.8,y2=-4).at(1,7).to(11,3).lw(2.4)
# B.get_point(index) returns a tuple (x, y).
# Use an asterisk to unpack this tuple into x and y.
# The allowed index range is from 0 to 100.
State().at(*B.get_point( 4)).dot().label('A')
State().at(*B.get_point(18)).dot().label('B', dx=0)
State().at(*B.get_point(48)).dot().label('C')
State().at(*B.get_point(91)).dot().label('D')
d.show()
from plotnik import *
x1 = 3
y1 = 2
x2 = 2*x1
y2 = y1
x3 = x1
y3 = 3*y1
with Drawing() as d:
d.set_config(
fontsize=31,
yname='$p$',
xname=r'$\rho$',
xlim=[0,7.5],
ylim=[0,7.5],
axes_arrow_width=0.16,
zero_x=0.4, # add zero as a xtick label shifted to x=-0.4
)
Linear().at(x1, y1).to(x2,y2).arrow().dot().tox().label(1,2, dy=-0.65)
Power(power=-0.5).at(x2, y2).to(x3, y3).arrow().label('',3)
Linear().to(x1,y1).arrow().dot('both').toy()
d.ax.set_yticks([y1, y3], ['$p_0$', '$3p_0$'])
d.ax.set_xticks([x1, x2], [r'$\rho_0$', r'$2\rho_0$'])
d.show()
from plotnik import *
v1 = 2
v2 = 5
p12 = 8
p34 = 3
with Drawing() as d:
d.set_config(
yname='$p$',
xname='$V$',
zero_x=0.5,
fontsize=28,
ylim=[0,10.7],
axes_arrow_scale=0.7,
center_x=4,
)
a=22
Linear().at(v1,p12).to(v2,p12).dot('both').arrow(size=a,pos=0.61).label(1,2)
Q1 = Adiabatic().at(v1,p12).to(p34, 'pressure').arrow(size=a,reverse=True)
Q2 = Adiabatic().at(v2,p12).to(p34, 'pressure').arrow(size=a)
v3 = Q1.end[0]
v4 = Q2.end[0]
Linear().at(v4,p34).to(v3,p34).dot('both').arrow(size=a).label(3,4, dy=-0.8)
d.show()
This method is used to create a smooth curve that must pass through the specified point, in this case, (3,60).
from plotnik import *
with Drawing() as d:
d.set_config(
aspect=1/20,
yname=r'$\alpha, \%$',
yname_y=103,
xname=r'$T,10^3 \rm{К}$',
xlim=[0,6],
ylim=[0,106],
zero_ofst=[0.2, 11.8]
)
d += (P1:= Power(2).at(0,0).to(3,60).lw(3) )
d += (L1:= Linear().at(5,90).to(6,90).lw(0) ) # This process is used solely to complete the Bezier curve with a tangent, hence 'lw=0' is specified.
Bezier().connect(P1,L1).lw(3)
d.ax.set_xticks([2,4])
d.ax.set_yticks([40,80])
d.grid(step_x=0.5, step_y=10, x_end=5, y_end=90)
d.show()
When plotting a complex curve as two separate processes (thus requiring two calls to ax.plot()
),
using a large linewidth may result in poor connections between the segments.
To resolve this, you can use Bezier()
to calculate the coordinates without plotting them.
Then, append these coordinates to the other process.
Matplotlib will seamlessly join these segments when plotting them in a single ax.plot()
call.
from plotnik import *
with Drawing() as d:
d.set_config(
yname=r'$V_{\rm погр},\rm{см}^3$',
xname=r'$\rho,\rm{г}/\rm{см}^3$',
ylim=[0,12],
xlim=[0,4.8],
aspect=1/4,
fontsize=18,
axes_arrow_width=0.2,
)
d += (B1:= Bezier(x=1.8,y=2.8).at(1, 10).to(4, 2.5).lw(0) )
x,y = B1.get_coordinates()
# Append straight line to x,y
x = np.append([0,1],x)
y = np.append([10,10],y)
d.ax.plot(x, y, lw=2.5, color='k')
d.ax.tick_params(length=0)
d.ax.set_yticks(np.arange(1,11,1))
d.ax.set_xticks(np.arange(0.5,4.5,0.5),
['0,5','1,0','1,5','2,0','2,5','3,0','3,5','4,0'])
d.grid(step_x=0.25, step_y=1, y_end=10, x_end=4)
d.show()
from plotnik import *
v1 = 8
u1 = 6
v2 = 3.5
with Drawing() as d:
d.set_config(
fontsize=31,
yname='$U$',
xname='$V$',
ylim=[0,7.4],
axes_arrow_length=1.1,
center=[10,0],
)
P1 = Power().at(v1, u1).to(v2, 'x').arrow().label(1,2).dot('both').tox().toy()
y2 = P1.end[1]
Power().to(0, 0).ls('--')
d.ax.set_xticks([v1, v2], ['$V_1$', '$V_2$'])
d.ax.set_yticks([u1, y2], ['$U_1$', '$U_2$'])
d.show()
from plotnik import *
v1 = 2
v2 = v1
v3 = 6
v4 = v3
t1 = 4
t2 = 2
t3 = 6
t4 = 8
with Drawing() as d:
d.set_config(
yname='$V$',
lw=3.2,
xname='$T$',
ylim=[0,7],
xlim=[0,10],
zero_x=0.5,
axes_arrow_scale=1.5,
)
Linear().at(t1, v1).to(t2, v2).arrow().dot('both').tox()\
.label(1,2, start_ofst=[0.5,0.2], end_ofst=[-0.8, 0.2])
Linear().to(t3, v3).arrow().tozero('start')
Linear().to(t4, v4).arrow(pos=0.7).tox().dot('both')\
.label(3,4, start_dx=-0.5)
d.ax.set_xticks([2,4,6,8], ['$T_0$','$2T_0$','$3T_0$','$4T_0$'])
d.show()
from plotnik import *
B = [0, 0.2, 0]
t = [0, 2, 4]
with Drawing() as d:
d.set_config(yname='$B,$Тл', xname='$t,$с',
xlim=[0,6.3],
xname_x=5,
yname_y=0.26,
ylim=[0,0.27],
zero_x=0.3,
axes_arrow_scale=1.5,
aspect=20,
)
d.ax.plot(t,B,'k-', lw=2.5)
d.ax.set_yticks([0.1,0.2], ['0,1','0,2'])
d.ax.set_xticks([1,2,3,4])
d.grid(step_x=1, step_y=0.05, x_end=4.2, y_end=0.21, lw=2, color='#333333')
d.show()
from plotnik import *
p1=3
v1=1
p2=1
v2=4
a = (p1-p2) / (v1-v2)
b = p1 - a*v1
vm = -b/(2*a)
pm = a*vm + b
with Drawing() as d:
d.set_config(
fontsize=24,
yname='$p$',
xname='$V$',
xlim=[0,5],
ylim=[0,4],
zero_ofst=[0.2, 0.38],
)
Linear().at(v1,p1).to(v2,p2).arrow(pos=0.3).dot('both').label(1,2).toy().tox()
State().at(vm,pm).dot().tox().toy()
Iso_t().at(vm,pm).to(v1*1.35,'volume').lw(1.4).col('#EE3344')
Iso_t().at(vm,pm).to(v2*1.16,'volume').lw(1.4).col('#EE3344')
d.ax.set_yticks([p1, p2, pm], ['$p_1$', '$p_2$', r'$p_\text{м}$'])
d.ax.set_xticks([v1, v2, vm], ['$V_1$', '$V_2$', r'$V\!_\text{м}$'])
d.grid(step=.5, y_end=3.5, x_end=4.5, color='#dddddd')
d.show()
Consider the followig syntax:
Linear().at().to().arrow().dot().label().toy().tox().tozero().col().lw().ls().zord()
.
.at(x1,y1)
: set starting point. Uses previous process last point if not set.
.to(x2,y2)
: set end point.
.arrow(size=None, pos=0.54, color='black', reverse=False, filled=True, zorder=3, head_length=0.6, head_width=0.2)
pos
sets position of the arrow on the line (from 0 to 1).reverse=True
rotates the arrow on 180 degrees.filled=False
doesn't look well but produces not filled arrow.
.dot(pos='end', size=8, color='black', zorder=5, marker='o')
.dot()
or.dot('end')
or.dot(pos='end')
adds only last point;.dot('start')
adds only start point;.dot('both')
adds two points..dot(size=25)
marker
are standart matplotlib markers, see full listzorder
can change the order it appears relative to other elements (useful to plot marker above or below grid or process etc.).
.label()
add 1 or two labels.
.tox(), .toy(), .tozero()
draw lines to, correspondingly, horizontal axis,
vertical axis and zero. Default linestyle is dashed line, can be changed like
.tox(ls='-')
. By default, draw lines both for start and end of the process.
Can be changed like .tox('end')
or .tox('start')
.
.col('red')
set color for the line.
.lw(1)
set linewidth for the process.
.ls('--')
set linestyle for the process.
.zord(5)
set zorder for the process.
-
Repair examples 7 and 8 so
d +=
won't be required. -
Revise the arrow positioning logic to ensure they are accurately centered.
-
In the
set_config()
function, add the capability to globally modify arrowsize, dotsize, and lw (line width) for processes.Introduce options in the
set_config()
to globally adjust the size of arrows, dots, and line width for processes. For instance, include settings likedots_all=True
,dots_size=10
andarrows_all=True
,arrows_size=23
. -
Integrate the feature to select different coordinates. For instance, if all processes are initially plotted in x, y coordinates, there should be an option to view them in transformed coordinates like 1/x, y^2. Example syntax could be:
d.transform_coordinates(newx = 1/x, newy = y**2)
. -
Address the issue where
d.save()
generates erroneous results when used without a prior call tod.show()
, ensuring reliable save functionality. -
.xtick()
andd.add_xticks()
use different codes for tick positioning. -
make
.xtick()
use matplotlibax.set_xticks()
method -
Improve the algorithm for automatic determination of positions and sizes for labels, ticks, and arrows.
-
When need
Bezier()
only to calculate coordinates, you have to add it toDrawing()
like so:d += (B1:= Bezier(x=1.8,y=2.8).at(1, 10).to(4, 2.5).lw(0) ) x,y = B1.get_coordinates()
Rewrite the code so one can use
B1 = Bezier(x=1.8,y=2.8).at(1, 10).to(4, 2.5).hide() x,y = B1.get_coordinates()
without adding it an actual figure.