In het vorige werkblad heb je gezien hoe een gas zich gedraagt in een zuiger waarbinnen wordt voldaan aan de wet van behoud van energie. In dat geval geldt , waarbij een exponent is die wordt bepaald door de vrijheidsgraden van het gas - in onze simulatie werken we met mono-atomaire gassen in 2D.
We hadden kunnen denken dat we de ideale gaswet zouden kunnen gebruiken. Echter, de temperatuur van het gas verandert door de werking van de zuiger - deze verricht arbeid.
Het meenemen van arbeid dat verricht wordt door, of op, het gas is een eerste uitbreiding die belangrijk is in Thermodynamica. Uitwisseling van energie (toevoegen of afvoeren van warmte) is een tweede uitbreiding. In deze simulatie onderzoeken we die uitbreiding waarbij we warmte aan en af voeren uit het controlevolume. Op die manier kunnen we ook isotherme simulaties doen.
Eerst herhalen we de nodige ingrediënten:
klasse voor het deeltje met bijbehorende functies
variabelen en randcondities van controle volume
functies voor een lijst deeltjes
Daarna voegen we de code toe voor het warmtecontact:
introduceren thermostaat
En vervolgens
bestuderen isotherm proces
Laden van eerdere code¶
De pakketten van Python en de constanten voor de simulatie:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation
from scipy.optimize import curve_fit
k_B = 1.380649e-23 # J/K (Boltzmann-constante)
T = 300.0 # K (kamertemperatuur)
m = 4.65e-26 # kg (massa luchtmolecuul)
f = 2 # aantal vrijheidsgraden (2D)
BOX_SIZE_0 = 10.0e-9 # m (10 nm × 10 nm startvolume)
N = 40 # aantal deeltjes
RADIUS = 0.15e-9 # m (moleculaire straal)
# Thermische beginsnelheid (2D)
V_0 = np.sqrt(2 * k_B * T / m) # m/s
# zuiger
V_PISTON_0 = -0.05 * V_0 # m/s (langzame compressie)
# Tijdstap
DT = 1.0e-14 # s
De klasse voor het gasmolecuul met de interacties:
class ParticleClass:
def __init__(self, m, v, r, R):
""" maakt een deeltje (constructor) """
self.m = m
self.v = np.array(v, dtype=float)
self.r = np.array(r, dtype=float)
self.R = R
def update_position(self):
""" verandert positie voor één tijdstap """
self.r += self.v * DT
@property
def momentum(self):
return self.m * self.v
@property
def kin_energy(self):
return 1/2 * self.m * np.dot(self.v, self.v)
def collide_detection(p1: ParticleClass, p2: ParticleClass) -> bool:
""" Geeft TRUE als de deeltjes overlappen """
return np.linalg.norm(p1.r - p2.r) < (p1.R + p2.R)
def particle_collision(p1: ParticleClass, p2: ParticleClass):
""" past snelheden aan uitgaande van overlap """
m1, m2 = p1.m, p2.m
delta_r = p1.r - p2.r
delta_v = p1.v - p2.v
dot_product = np.dot(delta_r, delta_v)
# Als deeltjes van elkaar weg bewegen dan geen botsing
if dot_product >= 0: # '='-teken voorkomt ook problemen als delta_r == \vec{0}
return
distance_squared = np.dot(delta_r, delta_r)
# Botsing oplossen volgens elastische botsing in 2D
p1.v -= 2 * m2 / (m1 + m2) * dot_product / distance_squared * delta_r
p2.v += 2 * m1 / (m1 + m2) * dot_product / distance_squared * delta_rDe randvoorwaarde van het volume. Hierbij is rekening gehouden met een bewegende zuiger die in het vorige werkblad is toegevoegd.
def top_down_collision(particle: ParticleClass):
global impulse_outward, box_height
if abs(particle.r[1]) + particle.R > box_height / 2:
particle.r[1] = np.sign(particle.r[1]) * (box_height/2 - particle.R)
impulse_outward += abs(particle.momentum[1]) * 2
particle.v[1] *= -1
def left_right_collision(particle: ParticleClass):
""" verzorgen van botsingen met wand links en rechts, die als zuiger kunnen bewegen """
global box_length, v_piston, impulse_outward, work
if abs(particle.r[0]) + particle.R > box_length / 2:
particle.r[0] = np.sign(particle.r[0]) * (box_length/2 - particle.R)
piston_velocity = np.sign(particle.r[0]) * v_piston
relative_velocity = particle.v[0] - piston_velocity # stelsel zuiger
particle.v[0] = -relative_velocity + piston_velocity # stelsel waarnemer
impulse_outward += 2 * particle.m * abs(relative_velocity)
work += 2 * particle.m * relative_velocity * piston_velocityDe functies voor het uitvoeren van de functies over de gehele lijst met deeltjes, waarbij we de werking van de zuiger ook hebben meegenomen:
def create_particles(particles):
""" Leegmaken en opnieuw aanmaken van deeltjes in lijst """
global box_length, box_height
particles.clear()
for _ in range(N):
vx = np.random.uniform(-V_0, V_0)
vy = np.random.choice([-1, 1]) * np.sqrt(V_0**2 - vx**2)
x = np.random.uniform(-box_length/2 + RADIUS, box_length/2 - RADIUS)
y = np.random.uniform(-box_height/2 + RADIUS, box_height/2 - RADIUS)
particles.append(ParticleClass(m=1.0, v=[vx, vy], r=[x, y], R=RADIUS))
def temperature(particles) -> float:
""" Berekent de temperatuur uit de gemiddelde kinetische energie """
total_kin_energy = 0.0
for p in particles:
total_kin_energy += p.kin_energy
temp = (2 / (f * k_B)) * (total_kin_energy / len(particles))
return temp
def handle_collisions(particles):
""" alle onderlinge botsingen afhandelen voor deeltjes in lijst """
num_particles = len(particles)
for i in range(num_particles):
for j in range(i+1, num_particles):
if collide_detection(particles[i], particles[j]):
particle_collision(particles[i], particles[j])
def handle_walls(particles):
""" botsing met wanden controleren voor alle deeltjes in lijst en gemiddeld bepaling druk """
global pressure, impulse_outward, box_height, box_length # om pressure buiten de functie te kunnen gebruiken
impulse_outward = 0.0
for p in particles:
left_right_collision(p)
top_down_collision(p)
pressure = 0.95 * pressure + 0.05 * impulse_outward / ((2 * box_length + 2 * box_height) * DT)
def take_time_step(particles):
""" zet tijdstap voor een lijst deeltjes en verwerk alle botsingen onderling en met wanden """
global box_length, v_piston
box_length += 2 * v_piston * DT # zowel links als rechts zuiger
for p in particles:
p.update_position()
handle_collisions(particles)
handle_walls(particles)
Test code¶
Voordat we de code aanpassen controleren we eerst of alles het doet. Hiervoor maken we zowel een -diagram als een -diagram (met als toevoeging een -diagram) tijdens de werking van de zuiger. Let op! De eenheden van deze grafiek kunnen niet kloppen omdat er niet in verrekend zit welke constanten jij hebt gekozen.
particles = []
volumes = np.zeros(1000, dtype=float)
pressures = np.zeros(1000, dtype=float)
temperatures = np.zeros(1000, dtype=float)
# times = np.linspace(1, 100, 100)
pressure = 0.0
work = 0.0
box_height = BOX_SIZE_0
box_length = BOX_SIZE_0 # zetten zuiger terug
v_piston = V_PISTON_0
create_particles(particles) # resetten deeltjes
for i in range(1000):
take_time_step(particles)
volumes[i] = box_length * box_height
pressures[i] = pressure
temperatures[i] = temperature(particles)
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(12, 3))
ax1.set_xlabel('Volume')
ax1.set_ylabel('Pressure')
ax2.set_xlabel('Volume')
ax2.set_ylabel('Temperature')
ax3.set_xlabel('Pressure')
ax3.set_ylabel('Temperature')
fig.tight_layout
ax1.plot(volumes, pressures, '-r')
ax2.plot(volumes, temperatures, '-b')
ax3.plot(pressures, temperatures, 'r--')
plt.subplots_adjust(wspace=0.3) # afstand tussen subplots
plt.show()
We zien inderdaad de druk, , met een exponent , wat verklaard wordt door de toename van de temperatuur tijdens het proces.
Maar wat gebeurt er wanneer we de temperatuur van het gas constant houden? Als we dat doen kunnen we controleren of de druk inderdaad invers proportioneel is met het volume, zoals de gaswet voorschrijft.
De thermostaat¶
In de werkelijkheid is de temperatuur van de wand een mate van de amplitude van de trillingen van de deeltjes waaruit de wand bestaat. De wederzijdse overdracht van de energie van die trillingen naar de kinetische energie van de gasmoleculen bepaalt het thermische contact tussen de wanden van het volume en het gas. In ons model bestaat de wand echter helemaal niet uit deeltjes maar hebben we een denkbeeldige lijn getrokken in de ruimte. We moeten daarom een wiskundige truc toepassen om de temperatuur van het gas te beïnvloeden.
Er zijn in de literatuur verschillende van dit soort trucs bedacht. Ze worden een ‘thermostaat’ genoemd. Elk van die type trucs hebben hun voor- en nadelen. In ons geval houden we het simpel: Op het moment dat een gasmolecuul botst met de wand (die een thermisch contact voorstelt), dan schalen we de snelheid van dit molecuul met (de wortel van) de verhouding tussen de veronderstelde temperatuur van de wand en de temperatuur die het gas op dat moment heeft:
Voor het gemak houden we de linker en rechter wand van het volume als zuiger en maken we het thermische contact aan de onder- en bovenwand.
def top_down_collision(particle: ParticleClass) -> None:
""" verzorgen van botsingen met wand boven en onder, die als thermostaat kunnen werken """
global box_height, set_temp, impulse_outward, heat
# Controleer of het deeltje de boven- of onderwand raakt
if abs(particle.r[1]) + particle.R > box_height / 2:
# Bepaal de schaalfactor voor de snelheid: T_wand / T_gas
# Als set_temp = 0 is de thermostaat uitgeschakeld
temp_factor = (set_temp / temperature(particles)) if set_temp > 0 else 1.0
# Zet het deeltje exact terug op de wand om doordringen te voorkomen
particle.r[1] = np.sign(particle.r[1]) * (box_height/2 - particle.R)
# Registreer de impulsuitwisseling met de wand door reflectie
# en door herschaling van de snelheid
impulse_outward += abs(particle.momentum[1]) * (1 + temp_factor**0.5)
# Houd de warmte-uitwisseling bij:
# ΔQ = E_kin · (T_wand / T_gas − 1)
heat += particle.kin_energy * (temp_factor - 1)
# Schaal de snelheid met √(T_wand / T_gas) zodat de temperatuur wordt aangepast
particle.v *= temp_factor**0.5
# Keer de verticale snelheidscomponent om (elastische botsing)
particle.v[1] *= -1
Uitleg thermostaat (opdracht 3)¶
temp_factor = (set_temp / temperature(particles)) if set_temp > 0 else 1.0
Bepaalt de factor waarmee de snelheid wordt aangepast; bijset_temp = 0staat de thermostaat uit.particle.r[1] = np.sign(particle.r[1]) * (box_height/2 - particle.R)
Zet het deeltje exact op de wand zodat het niet door de wand heen beweegt.impulse_outward += abs(particle.momentum[1]) * (1 + temp_factor**0.5)
Registreert de impulsuitwisseling met de wand door zowel de elastische reflectie als de snelheidschaling.heat += particle.kin_energy * (temp_factor - 1)
Houdt de warmte-uitwisseling bij: .particle.v *= temp_factor**0.5
Schaalt de snelheid met zodat de gemiddelde kinetische energie (temperatuur) wordt aangepast.particle.v[1] *= -1
Keert de verticale snelheidscomponent om en modelleert zo een elastische botsing met de wand.
Met deze nieuwe definitie van de functies, draaien we een simulatie waarin we zowel de temperatuur als de druk plotten als functie van het volume.
Om verdere belasting van de processor tot een minimum te beperken, berekent deze simulatie ook alvast de totale warmte en de totale arbeid tijdens het proces.
Deze worden opgeslagen in de arrays heats en works.
We zullen de resultaten van deze simulatie voor een aantal vervolgstappen gebruiken.
particles = []
N_steps = 5000
volumes = np.zeros(N_steps, dtype=float)
pressures = np.zeros(N_steps, dtype=float)
temperatures = np.zeros(N_steps, dtype=float)
heats = np.zeros(N_steps, dtype=float)
works = np.zeros(N_steps, dtype=float)
pressure = 0.0
work = 0.0
heat = 0.0
box_height = BOX_SIZE_0
box_length = BOX_SIZE_0 # zetten zuiger terug
v_piston = 0.2 * V_PISTON_0
create_particles(particles) # resetten deeltjes
set_temp = temperature(particles)
for i in range(N_steps):
take_time_step(particles)
volumes[i] = box_length * box_height
pressures[i] = pressure
temperatures[i] = temperature(particles)
heats[i] = heat
works[i] = work
if i%500==0:
print(i)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3))
ax1.set_xlabel('Volume')
ax1.set_ylabel('Pressure')
ax2.set_xlabel('Volume')
ax2.set_ylabel('Temperature')
fig.tight_layout
ax1.plot(volumes, pressures, '-r')
ax2.plot(volumes, temperatures, '-b')
plt.subplots_adjust(wspace=0.3)
plt.show()0
500
1000
1500
2000
2500
3000
3500
4000
4500

Tip
Het zelfstandig afschatten van p0 is lastig, maar omdat de relatie tussen en bekend is, kunnen we wel een eerste stap maken door twee van onze metingen te nemen ( en en en ), delen we die waarden door elkaar en nemen we links en rechts de logaritme krijgen we:
Zo hebben we een schatting () voor gevonden. Door die waarde nu te substitueren vinden we een initiele guess () voor de waarde van :
Let op het - teken! Waar we nog verder rekening mee moeten houden is dat een verkeerde waarde voor kan zorgen voor hele grote fouten in omdat die exponentieel mee telt. Omdat en fluctueren door het gering aantal deeltjes kunnen we beter waarden voor sampelen waarbij we een keuze maken moeten tussen grotere sample size voor reduceren van ruis en kleine sampele size om het effect van de zuiger klein te houden:
p0 = np.mean(pressures[100:110])
p1 = np.mean(pressures[N:N+10])
V0 = np.mean(volumes[100:110])
V1 = np.mean(volumes[N:N+10])
n_init = -np.log(p0/p1) / np.log(V0/V1)
a_init = p0 * V0**(-n_init)p0 = np.mean(pressures[100:110])
p1 = np.mean(pressures[-10:])
V0 = np.mean(volumes[100:110])
V1 = np.mean(volumes[-10:])
n_init = -np.log(p0/p1) / np.log(V0/V1)
a_init = p0 * V0**(n_init)
print(n_init)
print(a_init)-22.02020680064752
inf
/var/folders/h2/165_83yd51764dc6_scymr7r0000gn/T/ipykernel_8834/1767429496.py:7: RuntimeWarning: overflow encountered in scalar power
a_init = p0 * V0**(n_init)
#your code/answer
p0 = np.mean(pressures[100:110])
p1 = np.mean(pressures[-10:])
V0 = np.mean(volumes[100:110])
V1 = np.mean(volumes[-10:])
# Eerste schatting voor de exponent n uit de logaritmische verhouding
n_init = -np.log(p0 / p1) / np.log(V0 / V1)
# Eerste schatting voor de prefactor a
a_init = p0 * V0**(n_init)
print("initiële schatting n =", n_init)
print("initiële schatting a =", a_init)
def power_law(vol, a, n):
# Machtswet P = a · V^n
return a * vol**n
# Fit uitvoeren met curve_fit en opgegeven startwaarden
popt, pcov = curve_fit(
power_law,
volumes,
pressures,
p0=[a_init, n_init]
)
a_fit, n_fit = popt
print("fitresultaat a =", a_fit)
print("fitresultaat n =", n_fit)
# Fitresultaat visualiseren
V_fit = np.linspace(volumes.min(), volumes.max(), 500)
P_fit = power_law(V_fit, a_fit, n_fit)
plt.figure(figsize=(5, 3))
plt.plot(volumes, pressures, 'r.', alpha=0.4, label='simulatie')
plt.plot(V_fit, P_fit, 'k-', label=f'fit: P = a V^{n_fit:.2f}')
plt.xlabel('Volume')
plt.ylabel('Pressure')
plt.legend()
plt.tight_layout()
plt.show()
initiële schatting n = -22.02020680064752
initiële schatting a = inf
fitresultaat a = inf
fitresultaat n = -22.02020680064752
/var/folders/h2/165_83yd51764dc6_scymr7r0000gn/T/ipykernel_8834/4004252286.py:11: RuntimeWarning: overflow encountered in scalar power
a_init = p0 * V0**(n_init)
/var/folders/h2/165_83yd51764dc6_scymr7r0000gn/T/ipykernel_8834/4004252286.py:18: RuntimeWarning: overflow encountered in power
return a * vol**n
/var/folders/h2/165_83yd51764dc6_scymr7r0000gn/T/ipykernel_8834/4004252286.py:21: OptimizeWarning: Covariance of the parameters could not be estimated
popt, pcov = curve_fit(

Je ziet dat de exponent van dit -diagram net niet overeenkomt met de ideale verwachting.
#your code/answer
Bij kleinere volumes neemt de arbeid die de zuiger per tijdseenheid op het gas verricht toe, omdat de botsingen met de zuiger vaker plaatsvinden. De temperatuur stijgt daardoor sneller dan bij grotere volumes. Aangezien de warmteafvoer door de thermostaat evenredig is met het verschil tussen de actuele temperatuur en de starttemperatuur, blijft deze afvoer relatief beperkt zolang dat temperatuurverschil klein is. Bij kleine volumes is de toegevoerde arbeid groter dan de afgevoerde warmte, waardoor de thermostaat de extra energie niet volledig kan afvoeren en de temperatuur toeneemt.
Al met al lijkt het resultaat van de simulatie er redelijk uit te zien. Maar om een sterkere indicatie te hebben dat de simulatie correct is, moeten we weer een goede test verzinnen om de simulatie te verifiëren. In dit geval kunnen we opnieuw controleren of de simulatie voldoet aan de eerste hoofdwet.
Maak een grafiek die zichtbaar maakt dat de simulatie inderdaad voldoet aan de eerste hoofdwet (of niet). Als de code hierboven klopt, zijn de tekens voor arbeid en warmte in overeenstemming met de definitie van het boek. Let hierbij vooral goed op je eenheden. Om het jezelf makkelijker te maken kun je de verschillende grootheden onafhankelijk van elkaar plotten en de resultaten controleren, voordat je de definitieve grafiek opstelt.
#your code/answer
# Controle van de eerste hoofdwet: ΔU = Q − W
# Inwendige energie als functie van de tijd
U = np.zeros(N_steps)
for i in range(N_steps):
U[i] = (f / 2) * k_B * temperatures[i] * N
# Verandering van de inwendige energie t.o.v. het begin
delta_U = U - U[0]
# Grootheid Q − W uit de simulatie
Q_minus_W = heats - works
# Grafische vergelijking
plt.figure(figsize=(6, 4))
plt.plot(delta_U, label='ΔU')
plt.plot(Q_minus_W, label='Q - W')
plt.xlabel('Tijdstap')
plt.ylabel('Energie')
plt.legend()
plt.tight_layout()
plt.show()

In het boek wordt uitgelegd dat er maar twee vormen van energieoverdracht zijn van een systeem naar de omgeving. Dit kan via warmteoverdracht of via arbeid. Bijzonder is dat deze beide grootheden alleen de snelheid van de moleculen beïnvloeden. Toch zullen we in het volgende werkblad zien dat er een fundamenteel verschil zit in de werking van deze twee macroscopische grootheden.
Push je werk naar GitHub of ga door met de uitbreiding voor een beoordeling excellent.
In bovenstaande simulatie zijn de onder- en bovenwand allebei een thermostaat die naar dezelfde temperatuur toewerken. Als we die temperaturen verschillend maken, kunnen we kijken naar de thermische geleiding van het gas.
Maak nu een nieuwe simulatie met een volume met dezelfde breedte maar vier keer de hoogte, waarin de zuiger stilstaat.
Hou de dichtheid van de deeltjes hetzelfde, dus breid het aantal deeltjes uit naar 160.
Zet de temperatuur van de bovenwand op 150% en die van de onderwand op 50% van de starttemperatuur.
Door het warmtecontact via de bovenwand en de onderwand apart te rekenen, kan je zien hoeveel warmte er door het volume heenstroomt.
Controller of deze warmtestroom evenredig is met het temperatuurverschil en omgekeerd evenredig met de afstand. (nogmaals: hou de dichtheid van de deeltjes constant)
Geef de warmtegeleidingscoëfficiënt in W/K van het ideaalgas in je simulatie. (Opmerking: De warmtegeleidingscoëfficiënt wordt normaal gegeven in W/mK, maar in onze tweedimensionale simulatie valt de lengteafhankelijkheid er uit)