Version History
2.3.0
Adds shut-in periods to both generalized models — GeneralizedPLYield for an associated
phase and GeneralizedHyperbolic for a primary one — and fixes a grid misalignment that
made every interval volume the difference of two independent numerical integrations. The
shut-in work only widens the accepted parameter domain, so it leaves every 2.2.0 forecast
unchanged; the interval-volume fix is a breaking numerical change for the models that
integrate numerically.
- New behaviour
A
PLYieldSegmentcof0is a shut-in: the yield, and so the associated phase rate, is exactly zero from that breakpoint. Zero is absorbing — every later segment that inherits its value stays shut in — so only an explicit positivecbrings the phase back on production. Volume accumulated across a shut-in is zero, and the cumulative is flat there.A model
cof0shuts the phase in from the start. This is the only way to zero the pre-anchor branch, which spanst < segments[0].tand no segment can reach, soGeneralizedPLYield(0.0, m0, (PLYieldSegment(t, c=..., m=...),))is an associated phase that comes online att. Thecbound is now>= 0rather than> 0, which also means a fitter sampling fromget_param_descsmay drawc = 0.segments[0]may now overridec. It shares its time with the modelc, so two positive values there are still rejected as two sources for one quantity — but one of them being zero is not: each then describes a different branch, the modelcthe pre-anchor one and the override the segment itself. This admits both “produce, then shut in at the anchor time” and “shut in, then come online at the anchor time”.minandmaxdo not apply to a shut-in. Aminfloor otherwise resurrects it, and an associated phase can be shut in while the primary still flows. By the same rule, a yield of exactly zero now reads as zero rather than being clamped up tomin, which is also reachable when a long, steep anchor chain saturates below ~1e-308.A shut-in contributes no slope, so
Dreduces to the primary phase’s decline andbetatottimes it. Without this the stored segment slope leaked in as-m / t, reporting a decline for a phase that has no rate at all.A
qiof zero stores a decline of zero to match. It was already in bounds — a well shut in from the start — but reported the parametricDi, which is a live decline for a rate that is identically zero:MH(0, 0.8, 1.5)gaveD = 0.0181at one day. It now reports zero, as a shut-in segment does, and for the same reason: forcing it is what lets every derivative accessor answer without a special case. This is the whole hyperbolic family rather than one model, because a segment-freeGeneralizedHyperbolicis bit-for-bitMHand forcing it in one but not the other would break that equivalence.Only the built segment row is forced. The declared
Diandbiare untouched, so a model still round-trips through its own fields.D == 0requiresb == 0, the model’s own invariant, sobis forced with it. Two structural consequences follow, both for zero-rate models only, and neither changes a rate or a volume — those were zero already. ADtermbeside a zeroqiappends no terminal row, and does so silently: theDterm ignored: the last segment is flatwarning exists to tell a caller their cap is being dropped, and a well that never produced has no tail to cap. It is keyed on the model’s initial rate, so a model that produced and then shut in still warns. AndTHMbuilds its chain from the forced flat row, so a zero-qiTHMhas a different segment count from a producing one.GeneralizedHyperbolicextends its restart rule to the initial conditions: a segment stating a positive rate while inheritingDfrom a zeroqiraisessegments[0] restarts after a shut-in and must state its own D, exactly as it does after a shut-in segment.PLE,SEandDuongare unchanged. TheirDis a closed form that never referencesqi, so forcing it would mean the per-accessor special-casing this avoids everywhere else — and they carry no segment concept to be inconsistent with.
GeneralizedHyperbolictakes shut-in segments too, for symmetry: aHyperbolicSegmentqof0is a shut-in, the bound relaxing from> 0to>= 0. The rate is zero from that breakpoint until a later segment states one, and the cumulative is flat across it — production already recovered cannot change when the rate does, which is why cumulative volume was never overridable.Dandbare forced to zero on a shut-in segment, making it an exponential of zero decline. That is what letsD,beta,b, the chain fill andtime_at_rateanswer across it with no special case of their own — the alternative, a shut-in flag tested in each of them, is the same behaviour spelled out five times over. A stated non-zeroDorbis rejected rather than discarded:segments[i] has q == 0, which requires D == 0 and b == 0.Zero is absorbing for the decline as well as the rate, since the resolution walks inherit the forced zeros like any other value. A segment that inherits the rate stays shut in; one that states a positive rate while inheriting
Dwould inherit a flat forecast at that rate for the rest of time, and an EUR to match, so it must state its own decline:segments[i] restarts after a shut-in and must state its own D.bmay still inherit the zero, which restarts exponentially.Dtermneeds no new handling. A trailing shut-in is flat, so the existingDterm ignored: the last segment is flatwarning already covers it.The model’s own
qiof zero was already in bounds, and a later segment stating a rate already brought the well online from it. That case is unchanged.
- Bug Fix
Breaking numerical change:
monthly_volandinterval_voldifferenced two separate numerical integrations._integrate_withbuilds its log-spaced grid from the largest time it is given, soN(t)andN(t - 1 month)sampled the shared early interval at different points, and the ~1e-6 relative error on each cumulative — an absolute error scaled by the cumulative — did not cancel. Both now take every endpoint they need from one integration. This affects the numerically integrated models only (PLEand the associated-phase yields);MH,THM,SE,Duongand the other hyperbolics have closed-form cumulatives and are unchanged.Every figure below is measured at
[30, 90, 365.25, 1826.25, 3652.5, 10957.5]days, and depends on that array: the grid is built from its largest element, so a shorter one puts the same step in a differently spaced neighbourhood.monthly_volwas the worse of the two, because every element crossed two grids. The residual is an absolute error, so it is unbounded against a monthly volume that has decayed: forPLE(1000, 0.8, 1e-4, 0.5)it was a constant-3.98e-6, which is to say a negative monthly volume at every time past about a year. Against adaptive quadrature the same model improves from-3.5e-4to+6.7e-5relative error at one year, and anMH+PLYieldsecondary from6.8e-6to1.9e-6at thirty years.interval_volcrossed two grids in one place, theprependthat anchors the first interval. With the defaultt0, which ist[0], that interval is empty and the method returned the second grid’s discretization noise instead of zero —3.8968e-04for the model above. Later intervals were already consistent, and are unchanged whent0is left to default.Both are now faster, since each performs one integration rather than two: a 359-point monthly series on
PLEgoes from 0.77 ms to 0.38 ms per call.
D,betaandbreturned a silentnanatt == 0for any associated phase whose yield is flat at the origin. The yield term is-m / t, which is-0 / 0there when the slope is zero, and the surroundingerrstatesuppressed the warning. A flat yield contributes no decline, so the limit is zero andDnow reduces to the primary phase’s own decline; a non-zero slope keeps the signed infinity that is its real limit. Three parameterizations reach a zero slope at the origin, and only the first is new in this version:a model
cof zero, i.e. shut in from the start;a
minclamp, since the yield is0.0att == 0by convention and so is always at or below the floor there;a flat
m0, which is whatPLYield(1.2, 0.0, 0.6, 180.0)— the parameterization in this project’s own README — has always been.
Breaking numerical change: the integration grid is seeded on both sides of every time at which the rate may jump. A step fell between two log-spaced grid points and the trapezoid rule integrated a ramp across the gap, and the excess was carried by every later cumulative — an EUR error rather than a local wobble. Against quadrature split at each breakpoint, and quoted as the MAXIMUM relative error across each case’s evaluation array rather than the error at one flattering point: a
coverride on the yield ran 2.8e-4 and now runs 6.0e-08; a primary phase restarting from a shut-in ran 5.5e-3 and now runs 3.1e-06 — the latter being the local-refinement figure below, not the straddle alone. This affects any numerically integrated cumulative whose rate steps, so an associated phase attached to a segmented model — and it predates shut-ins by as long ascandqoverrides have existed.Models report their own candidate jumps through a new
_rate_breakpointshook, empty by default. An associated phase reports the primary phase’s as well, since it multiplies that rate and inherits its steps.A segment boundary is only a genuine step where the segment overrides its level. Both kinds are reported: a continuous boundary costs two grid points and refines the integral slightly, where missing a real step is permanent.
Each breakpoint also gets a local grid, spaced logarithmically from the breakpoint rather than from zero. A separate problem from the step: the global grid’s density falls off as
tgrows, which is right for a decline beginning att = 0and wrong for a segment restarting steeply later. A restart at 800 days held 4.7e-05 on the global grid alone against the ~2.3e-06 a smooth model gets at the samen_grid, and now holds 3.2e-06.The local grids are added to the global one, not carved out of it. Splitting
n_gridacross segments was measured and is worse — it starves whichever segment holds the long tail, taking a smooth tail after three early breakpoints from 4.7e-07 to 8.7e-06. Each interval takes a quarter ofn_griduntil there are more than four of them, after which they share a budget ofn_grid, so the grid at most doubles however many segments a model has. A model with no breakpoints is untouched, andPLEreturns bit-identical cumulatives.
The associated phase’s
bdivides byDtwice rather than byD * Donce. The square of a smallDgoes subnormal and then to zero while the quotient itself stays representable, so squaring lost accuracy and then the value outright. For a yield slope of7.28e-158against a primary phase contributing no decline the exactbis-1 / m: squaring returned-1.34e157against the true-1.373e157, and-infonce the square underflowed completely. Two divisions never form the intermediate. Found by hypothesis, reachable once a zeroqiforces the primary phase’s decline to zero and leaves the yield’s own slope as the whole ofD.The associated-phase yield function suppresses its own expected degeneracies rather than relying on the
errstateof whichever caller happens to wrap it, which did not cover them:Dsetsdivideandinvalidbut neverover, so a denormal anchor time leakedoverflow encountered in dividethroughD,betaandb, whilegor,rateandcumleaked all three categories. A denormal anchor time needs no special setup —t0is bounded below only by zero — and a non-positive one needsvalidate_paramsdisabled. Values are unchanged; every one of these points was already resolved by an explicit mask.monthly_vol_equivignored itst0argument. The body opened witht0 = np.atleast_1d(0.0), overwriting the parameter, so the first interval always ran from zero however the method was called. It now honourst0and still defaults to zero, as documented. Its integration was never misaligned — the discarded_Nfn(0.0)is exactly zero — but it now shares the single-integration path, which is what makesmonthly_vol_equiv(t, t0)exactlyinterval_vol(t, t0)per day, scaled to a month.monthly_vol_equivreturnednanfor a zero-width interval, and leaked theRuntimeWarningfrom the0 / 0to the caller. A zero-width interval is a definite integral whose bounds coincide, so it holds no volume and there is no rate to average over it: the result is now0. Reachable both from at0equal tot[0]and from a repeated time insidet.Breaking API change:
t0is typedfloat | Nonerather thanFloatLike | Noneoninterval_volandmonthly_vol_equiv, and a value carrying more than one time now raises. It anchors one interval rather than being a series, and a longer sequence silently shifted the positional alignment the differencing depends on —interval_vol(t, t0=[10, 20])returned one value per element oft0plustrather than one per requested time, in this version and every version before it. Every scalar form still works, a numpy scalar and anintvia the numeric tower included; this narrows only what the package ships inpy.typed, so a type-checked consumer passing a sequence sees it at the call site.
- Unchanged
PLYield.ckeeps its> 0bound. Both of its segments anchor at the same(t0, c), so a zero there is zero for all time with no way back — that isNullAssociatedPhase, not a shut-in period. Only a model that can restart an anchor chain can express one.The single-breakpoint equivalence to
PLYieldis still bit-for-bit: with no first-segment override the anchor chain is seeded exactly as before.
2.2.0
Adds arbitrary-segment forecasting to both the primary and associated phases, two new
hyperbolic models, and a rate inversion. MH and THM produce bit-for-bit identical
forecasts to 2.1.0 for t >= 0; their accepted parameter domain narrows, and the behaviour
of every model at t < 0 changes — see the breaking changes.
- New Models
Hyperbolic— the plain single-segment Arps hyperbolic, takingqi,Di,biand nothing else. It is bit-for-bitMH(qi, Di, bi): a modified hyperbolic given no terminal decline is a hyperbolic, and this states that in the type rather than leaving it implied by an omitted argument. Verified identical acrosssegment_params,rate,cum,D,beta,bandtime_at_rate.It takes no
Dterm, which is the only difference fromMH. The decline falls for all time instead of flattening onto a terminal exponential: over 30 yearsHyperbolic(1000, 0.8, 1.5)recovers 617,999 against 555,128 forMH(1000, 0.8, 1.5, 0.08), and the gap keeps widening.Whether the uncapped tail leaves an EUR depends on
bi, which is not the same as forMH. The Arps cumulative converges toqi / ((1 - bi) Dnom)forbi < 1— 295,493.457 forHyperbolic(1000, 0.8, 0.5), reached in the limit — and diverges forbi >= 1, where there is no EUR at all. This is not a difference fromMH:MH’sDtermdefaults to 0, soMH(qi, Di, bi)diverges identically, as the bit-for-bit equivalence requires. AnMHgiven a non-zeroDtermhas an EUR for everybi— aboveB_EPSILONbecause the appended terminal exponential converges, at or below it because the tail is already exponential,Dtermis ignored with a warning, and the primary segment converges on its own.Bounds are
MH’s minusDterm:qi >= 0,0 < Di < 1,0 <= bi <= 2. LikeMHandTHMit requires aDithat actually declines — see the breaking change below — and permitsbi = 0, the exponential limit.
GeneralizedHyperbolic— an Arps primary-phase model taking an arbitrary number of caller-specified segments, given asHyperbolicSegmentinstances. Each segment is by default continuous in rate and decline with the one before it; cumulative volume is continuous always. With no segments it is bit-for-bit identical toMH, i.e.MH(qi, Di, bi, Dterm) == GeneralizedHyperbolic(qi, Di, bi, (), Dterm), terminal segment included. Segment times must be finite, positive, and strictly increasing; a givenqmust be finite and positive, a givenDfinite and less than 1, and a givenbfinite.NaNis rejected explicitly rather than read as an inherited slot.Segments may incline or be flat.
Diand a per-segmentDhave no lower bound, so a negative value inclines: the secant definitionD = 1 - q(1 year) / qifixes the meaning exactly, makingD = -0.5a 1.5x rate after a year andD = -9a tenfold rise, both of which a well can do after a restimulation.D = 0holds the rate. The upper bound onDstands at 1: a decline of 100% per year consumes the whole rate within the year and converts to an infinite nominal decline.biand a per-segmentbare bounded only by being finite.THMenforces both[0, 2]andbi >= bf >= btermbecause its segments model one specific transient-to-boundary transition, not Arps in general, so this model constrains neither the magnitude ofbnor its monotonicity between segments — a restimulation genuinely raisesb.MHandTHMkeep their[0, 2].Dandbmust agree in sign within a segment. A segment either declines (D > 0,b >= 0) or inclines (D < 0,b <= 0), and a flat segment (D == 0) must haveb == 0.bisd/dt(1/D), so abopposing its ownDdrives the decline through zero at the polet = -1 / (b D)and out the other side — which is why the segment functions returnNaNpast it — and a flat segment has no decline for a non-zerobto act on. The check runs against the resolved exponent, so a segment supplying one of the pair and inheriting the other is caught too.A terminal decline caps a hyperbolic tail, whose decline falls with time until it reaches
Dterm. WhereMHraisesDi < Dterm, this model clamps the terminal segment forward to the last segment’s start time — that segment’s decline is not known until the chain is built, so a caller cannot be asked to guarantee it in advance. A last segment that is already exponential, flat, or inclining has no crossing at all, soDtermcannot be applied and is ignored with a warning.Its accepted domain is a strict superset of
MH’s: it also permits a flat or inclining segment, an unboundedb, andDi < Dterm. The equivalence above therefore holds whereverMHis constructible.
IncliningHyperbolic— an Arps hyperbolic run in reverse, withDiandbiboth required negative, so the rate rises. It models a period of increasing rate: a well cleaning up after completion, ramping onto compression, or recovering from an offset frac hit.IncliningHyperbolic(qi, Di, bi)is bit-for-bitGeneralizedHyperbolic(qi, Di, bi, ())— the named, bound-checked case of it, the mirror of whatMHis for a declining forecast.It takes no
Dterm: a rising rate never reaches a terminal decline, so there is nothing to cap. Both the rate and the cumulative volume are therefore unbounded — it models one period, not a whole well, and has no EUR on its own. UseGeneralizedHyperbolicto incline and then decline.A negative
Dialone is not sufficient. The incline must survive conversion as a representable decline:nominal_from_secantfloors any magnitude belowMIN_EPSILONto zero, and the conversion then divides byDAYS_PER_YEAR, so aDias large as-8.13e-306still lands belowMIN_EPSILONonce stored — a flat forecast, not an inclining one. The threshold is shared withMH/THMand withGeneralizedHyperbolic’s sign rule, which is what keeps the three in step.
GeneralizedPLYield— a power-law associated-phase model taking an arbitrary number of breakpoints, with the anchor valuecat the first breakpoint, given asPLYieldSegmentinstances. A single breakpoint is bit-for-bit identical toPLYield, i.e.PLYield(c, m0, m, t0) == GeneralizedPLYield(c, m0, (PLYieldSegment(t0, m=m),)). Breakpoint times must be finite, positive, and strictly increasing, and each slope finite and within[-10, 10].NaNis rejected explicitly. The validation is written asnot np.all(valid)rather thannp.any(invalid), because every comparison againstNaNis false and the latter form would accept aNaNbreakpoint and silently return an all-NaNyield function. The same form is used throughout the new validation.
- New Segment Types
HyperbolicSegment— one segment of aGeneralizedHyperbolic, with keyword-only optional fields whereNonemeans continuous from the previous segment: an omittedbcontinues the preceding exponent, an omittedDleaves the decline continuous, and an omittedqleaves the rate continuous. Supplyingqsteps the rate at that time, and supplyingDprescribes the decline there. Cumulative volume is never overridable. A per-segmentDis a secant effective decline per year, matchingDiandDterm; its conversion to nominal-per-day depends onb, sobis resolved first, including whereDis given andbinherited.PLYieldSegment— one segment of aGeneralizedPLYield, with the sameNone-means-inherit convention: an omittedmcontinues the preceding slope, an omittedcleaves the yield continuous. Supplyingcsteps the yield at that breakpoint and restarts the anchor chain, so the model is value-continuous at every breakpoint unless that segment setsc.con the first segment raises, since the model’s owncalready fixes the value there.The optional fields are keyword-only on both, because
HyperbolicSegment(365.0, 0.3)would otherwise setqwhile the equivalent builder tuple(365.0, 0.3)meansb— and likewisePLYieldSegment(180.0, 0.6)against(180.0, 0.6).GeneralizedHyperbolic.from_segmentsandGeneralizedPLYield.from_segmentsbuild the same models from plain tuples, disambiguated by arity:(t, b),(t, D, b)or(t, q, D, b)for the hyperbolic,(t, m)or(t, c, m)for the yield. The shape parameter is always last and short forms omit the level.
- New Methods
MultisegmentHyperbolic.time_at_rate(q)inverts the rate function, soHyperbolic,MH,THM,GeneralizedHyperbolicandIncliningHyperbolicall gain it. It answers both the forward question — time to an economic limit — and the backward one — how far a forecast can be extrapolated — with the same call.Each segment is inverted only over the times it governs, using the same bracketing as
rate. That is not cosmetic: onMH(1000, 0.8, 1.5, 0.08), whose terminal segment begins at 2884 days,rate(5000)is 32.84892, and inverting that with the initial segment’s parameters gives 5990 — wrong by 990 days. A rate aboveqiis extrapolated backwards off the first segment, giving a negative time.The pole needs no separate accessor: it is
time_at_rate(inf), which is-1 / (b D)for a declining hyperbolic,-inffor an exponential (no pole, so it can be backed up indefinitely), and+inffor an inclining segment (no backward pole — an inclining rate diverges forward instead). At_minwould be a misnomer, since the pole bounds whichever direction the rate grows in.Where a model mixes inclining and declining segments the rate is not monotonic and a given
qmay occur several times; the earliest is returned, which is what backing a forecast up wants. A negative rate returnsNaN. A rate of zero returnsinffor a declining model — reached only in the limit — but a finite time for an inclining one, which was at zero in the past rather than the future.Accuracy degrades approaching the pole, where
expm1saturates and the offset from it loses precision. OnMH(1000, 0.8, 1.5)a rate 9.4e6 timesqistill round-trips to 1e-6; withbi = 10only 14x does, since a larger exponent puts a given rate multiple nearer the pole. Backing a forecast up by a plausible amount is unaffected.
PLYield.shift(dt)andGeneralizedPLYield.shift(dt)re-anchor a fit made against the wrong first-production date, moving the pivot or every breakpoint later bydtdays. This re-anchors rather than reproducing the original curve — late-time yield changes by roughly(t0 / (t0 + dt)) ** mbecause the power law’s origin moves — so a rigorous correction is still a re-fit. The hyperbolic models need no equivalent: they extrapolate backwards directly, which extends the same curve instead of moving its origin.
- Breaking: the yield models return
NaNfort < 0 A power law is not real-valued there:
(-30.4/180) ** 0.6is-0.106 + 0.327j. The previous implementation floored the negative time ratio atMIN_EPSILON, which produced a constant identical for every negativetthat flipped between3.07e-185and4.69e+184with the sign ofm(forc=1.2; the constant scales linearly withc) — an artifact of the floor carrying no information aboutt.t == 0keeps its0.0convention andt > 0is unchanged. Useshift()to model the period before the anchor.
- Breaking: the yield models return
- Breaking: the hyperbolic models extrapolate backwards instead of returning zero
MultisegmentHyperbolic._vectorizemasked each segment ont >= t_startwith the first segment starting at0, so no segment ever claimed a negativetand the zero-filled initial value survived:MH(1000, 0.8, 1.5).rate(-500)returned0, indistinguishable from a dead well. The first segment now claims everything below the next boundary, so a forecast fit against a first-production date that was too late can be walked backwards.MHandTHMchange here; results fort >= 0are bit-for-bit unchanged. The three models added in this release —Hyperbolic,GeneralizedHyperbolicandIncliningHyperbolic— share the behaviour from the start.cumbeforet = 0is negative, being the volume back to thet = 0baseline as a signed offset.Far enough back, a hyperbolic segment reaches the pole at
t = -1 / (b D), where1 + b D tvanishes. The rate diverges toinfthere — andcumconverges to a finiteq / ((1 - b) D)forb > 1, where the integral is convergent. Beyond the pole every output isNaN:rateandcumalready were, butD,betaandbremained algebraically defined and reported a plausible decline for a domain with no rate.All five agree for any
bthe rate function treats as hyperbolic, i.e.b > 1e-10. Below that the rate function switches to the exponential form, which has no pole, whileDandbstill test for one — so abbetween1e-308and1e-10disagrees, but only past|t|of order1e12days. That threshold difference between the rate and decline functions predates this release and is left alone: aligning them would changeMHandTHMresults for such abat everyt, not just beyond the pole.
Breaking:
MHandTHMreject aDithat does not declineHyperbolic, new in this release, is bound the same way — which is part of what makes it bit-for-bitMH.A
Diof 0 givesq(t) = qifor allt. That is a flat forecast, not a hyperbolic model — every use ofbis multiplied byD, so the exponent has nothing to act on, andMHsilently ignored itsbithere. The descriptor bound narrows from[0, 1)to(0, 1).A second check rejects a
Diwhose stored nominal-per-day decline lands belowMIN_EPSILON, which is the same flat forecast reached another way. That threshold sits about 2.5 decades aboveMIN_EPSILONin secant terms — around8.13e-306— because the conversion divides byDAYS_PER_YEAR. It is the threshold the segment-chain fill already uses when it zeroes the exponent of an underflowed decline, so a model can no longer store the(D == 0, b != 0)pair that the chain fill removes from every later row. Note this enforces internal consistency, not a visible decline: a stored decline of1e-300is accepted and still reads flat at double precision.GeneralizedHyperbolicdeliberately still accepts a flat forecast, with a matchingbof 0, since flat segments are part of what that model exists to express.IncliningHyperbolicmakes the mirror-image check.
- Breaking: the
THMtransient functions raise whenmpmathis missing mpmathis a development dependency, not a runtime one, so an ordinarypip install petbox-dcadoes not have it.THM._transDfnprinted a message to stderr and returned all-NaN, on the theory that a missing optional dependency should degrade rather than fail. It did not degrade cleanly. Onlytransient_Dandtransient_betapropagated theNaN:transient_D, transient_beta all NaN -- an honest failure transient_rate a CONSTANT 1033.4 at every t transient_cum that constant integrated, so linear in t transient_b finite, plausible-looking values
measured on
THM(1000, 0.8, 2.0, 0.8, 30.0)and unchanged across all three terminal configurations. A caller got numbers rather than an error, from a library whose output feeds economics.They now raise
ImportErrornaming the remedy, chained from the original so the cause is not lost.transient_rate,transient_cum,transient_Dandtransient_betaraise in every configuration.transient_bis the exception: it reaches for the transient integral only to locate an exponential terminal boundary, so it raises for abterm-terminated model and returns finite values otherwise — correct either way, and asserted both ways.The other functions —
rate,cum,D,beta,b— never neededmpmathand are unaffected, which the error message states and the test asserts against values captured beforehand. The test blocks the import through asys.meta_pathstub, so it runs correctly in an environment that hasmpmath.
- Breaking: the
- Bug Fix:
NullPrimaryPhaseandNullAssociatedPhaseare hashable Both were declared
@dataclassrather than@dataclass(frozen=True). A non-frozen dataclass gets a generated__eq__and has its__hash__set toNone, so both raisedTypeError: unhashable typein aset, as adictkey, or as anlru_cacheargument — while every other model, being frozen, was hashable. They are now frozen like their siblings, which is what the documented design pattern always claimed. Neither class declares a field, and everything that writes to a model instance already goes throughobject.__setattr__, so nothing else changes: rate and cumulative volume are still zero for allt, and an unattached secondary or water phase still resolves to one of these and returns zero.The hashability test now asserts its own coverage against the module, so a model added later cannot skip it. That assertion is what surfaced this.
- Bug Fix:
- Breaking:
PLYieldnow validates all six of its parameters Previously only
cwas bound-checked.DeclineCurve.validate_paramsdefaults to a one-element list and__post_init__zipped it against the descriptor list, so the remaining five checks were silently skipped.PLYieldwas the only model affected — every other model already sized its flag list correctly. Constructions withm0outside[-10, 10],moutside[-1, 1],t0 <= 0, or a negativemin/maxnow raiseValueErrorinstead of being accepted.
- Breaking:
- Breaking: every model rejects a non-finite parameter
The bound checks could not: every comparison against
NaNis False, soparam < lower_boundaccepted it, and a parameter with no upper bound acceptedinf. All seven models were affected —MH,THM,PLE,SE,Duong,PLYieldandGeneralizedPLYieldall constructed withNaN/infparameters.The consequence was worse than a
NaNforecast._integrate_withzeroesNaNgrid points so that one degenerate point cannot poison every later trapezoid, which meant aNaNparameter producedNaNrates but a definite zero cumulative — a silent zero EUR rather than a visible failure. ANaNarriving from a fitter would have been reported as a valid forecast of nothing.ValueError: <name> is not finiteis now raised at construction. Finite extremes such asc=1e300are still accepted, andvalidate_paramsstill opts out per parameter. Sequence-valued parameters (GeneralizedPLYield.segments,GeneralizedHyperbolic.segments) continue to validate their own contents.
- Breaking: an ignored
Dtermnow warns MHandGeneralizedHyperbolicdiscardDtermwhen the last segment is already exponential, flat, or inclining, since a decline that is constant or rising never reachesDterm. That was silent; it now raises aRuntimeWarningnaming the reason. Forecast values are unchanged. It matters most for a flat tail, where the discarded cap means the forecast produces volume forever.THMis unaffected: it builds its own terminal row rather than going through the shared helper, and itsbterm/ttermpair has different semantics.Standard warning filters apply, so a batch of models tripping the same case reports it once per process rather than once per model. Use
warnings.simplefilter('always', RuntimeWarning)to see every occurrence.
- Breaking: an ignored
- Breaking: numerical integration rejects
t < 0andt = inf _integrate_withreturnsNaNfor both instead of corrupting the result at every other time. It merges the requested times into its own grid, so one bad entry poisoned the whole call:A negative entry moved the lower limit of integration from
0tomin(t), and every returned value picked up the area over[min(t), 0]— which theNaNzeroing turned into a definite number rather than a visible failure.PLE(1000, 0.8, 0.1, 0.5).cum([30, 100, 365, 1000])returns ~``1819``; prepending a single-30.0turned those same four entries into ~``16819``, an 8.2x error at positive times.An infinite entry made
log10(t_max)infinite, collapsing the whole log-spaced grid to[nan, inf, ...], so every finite time was integrated over two or three points.monthly_volwent negative.
PLE,SE,Duongand the power-law yields all integrate numerically and all raise time to a non-integer power, so none is real-valued before0. An infinite time is not answerable by quadrature at all: the analytic cumulatives have a closed-form limit there, this does not, and a truncated integral would read as an EUR. The grid also now spansmax(t)rather thant[-1], so an unsorted request cannot fall outside it.
- Breaking: numerical integration rejects
- Bug Fix
cumcould returnNaNunder a perfectly finite rate._Ncheckfalls back to a linear form when its volume coefficient overflows, but guarded onq / Dwhile actually usingq / ((1 - b) D)— the1 - bfactor shrinks the denominator further, so the coefficient goes infinite at a much largerDthan the guard caught. An infinite coefficient times the zeroexpm1of a zero-width segment boundary isNaN, which_integrate_withwould then read as a definite zero volume. The fallback now tests the coefficient it uses.A
NaNtime returned0.0from every hyperbolic model with two or more segments._vectorizemasks the first segment from below and every segment from above, and every comparison againstNaNis false, so aNaNtime was claimed by no segment and fell through as the zero initialiser — a rate and a cumulative volume of exactly zero for an unanswerable time. Single-segment models, which have no upper mask, already returnedNaN. All row counts now agree.A
NaNtime is likewiseNaNfrom a flat or spent segment. The constant-rate branches of_qcheck,_Ncheckand_Dcheckignoretentirely, so a single-segment flat model answeredrate(NaN)with its rate andcum(NaN)with a definite volume whileb(NaN)wasNaN. All five outputs now agree for every model shape. An infinite time is deliberately notNaN:bremains the last segment’s exponent in the limit, matchingrate, which saturates to zero there.A large exponent overflowed the forward product
D b dt, andlog1p(inf)then discarded the value:GeneralizedHyperbolic(1000, 0.9, 308, ())reported a rate of 0 at 10 years where it is 99.26, and a cumulative ofinf. A wrong number, and one that moved with the evaluation time — atb = 307the rate was right at 1 and 10 years and wrong at 100. Recovered in log space, exact at that magnitude sincelog1p(x) == log(x). Separately,expm1overflows aboveLOG_EPSILONwhile its product withq / ((1 - b) D)is often still representable — that coefficient is tiny exactly when the exponent is large — so the coefficient is folded into the exponent rather than the exponent saturated first.Bounding
bwould not have fixed either: the threshold is set byb * -log1p(-Di), so it slides fromb = 1024atDi = 0.5tob = 19.3atDi = 1 - 2**-53, and it depends on the evaluation time as well.Past that range the conversion itself saturates, and a model built on an infinite nominal decline produced an all-
NaNforecast: paired with a non-zero exponent it makesD b dtaninf * 0at the segment’s own start. AtDi = 0.9one ULP inbi— 308.2547155599167 against 308.25471555991675 — separated a plausible forecast from that. It is now rejected at construction, naming the offending segment. Exempt are a row notcan reach (THMproduces one when a denormalbfoverflows its terminal time to infinity) and an infinite decline on an exponential segment, which is a well-defined instant shut-in.
A
GeneralizedHyperbolicsegment whose inherited decline underflowed to exactly zero kept its non-zero exponent, sob(t)reported a value beside a zeroD— the pair the constructor rejects on its inputs. It happens when1 + D b dtoverflows across a long span, which needs the largebthis release made legal. The exponent is now zeroed with the decline. Rate and volume are unchanged, since every use ofbis multiplied byD. Unreachable forMHandTHM, whose declines and exponents are both bounded.THM.transient_rateandtransient_cumraisedValueErrorfor some valid parameterizations._transqfnassigned a full-length right-hand side into a masked left-hand side, so it failed whenever the overflow mask excluded even one element — “cannot assign 255 input values to the 228 output values where the mask is true” — and it reported an overflowing exponent as a rate of zero rather than infinity. The exponent is now saturated as_qcheckdoes it. Pre-existing, and unreachable from the segmented functions.THMraisedZeroDivisionErrorwhen its terminal-segment branch took the reciprocal of a zero decline. The live route is abtermthat converts to zero, which is no terminal cap at all; the other route was a flat forecast, now rejected outright. Both collapse the terminal time ontot3, the path an unusablebfalready took. Guarded against exact zero, the only value that raised, so nothing that previously worked changed.validate_paramsgiven as a list left the instance unhashable, and given as a generator lost its opt-outs. A frozen dataclass hashes its field tuple, so a list there raisedTypeErrorat the firsthash()rather than at construction; a generator was consumed by the single read in__post_init__, so anything rebuilding the instance throughdataclasses.replace()—shift(), for example — silently re-enabled every check the caller had opted out of. It is now normalized to a tuple.GeneralizedHyperbolicsilently dropped every segment whensegmentswas given as a generator. Validation iterates the sequence several times, and the first pass exhausted it; an empty sequence is legal and reduces toMH, so the model constructed cleanly and returned a plausible single-segment forecast.GeneralizedPLYieldescaped only because its empty check raisedTypeErrorfromlenfirst. Both now materialize the sequence before validating it.The sixth
PLYieldParamDescwas named'min'while describingmax, soPLYield.get_param_desc('max')raisedKeyErrorand the descriptor list reportedmintwice.DeclineCurve.__post_init__no longer skips bound checks whenvalidate_paramsis shorter than the descriptor list; short lists are padded withTrue.
- Refactor
MultisegmentHyperbolic’s sign-assuming guards are now magnitude tests.MIN_EPSILONissys.float_info.min, a tiny positive number, so everyif D < MIN_EPSILONread as “Dis zero or negative” where “Dis negligible in magnitude” was intended — an inclining segment (q > 0,D < 0,b < 0) fell into theD == 0constant branch and returned a flat rate. The pairedb <= B_EPSILONshape tests changed with them. This is what made an inclining model possible at all;MHandTHMcannot pass a negativeDorbinto the base, so their results are unchanged.THM’s inline segment-chain loop andMH’s hand-computed terminal row are now a shared_fill_segment_chainand_append_terminal_segment, which the new models also use. The chain fill is conditional onisnan, so a supplied rate or decline is an override rather than being overwritten.rateandtime_at_rateshare one_segment_window. The round trip is exact only because a timetime_at_ratereturns falls in the window of the very segment it inverted, which is then the segmentrateevaluates; two copies of that windowing could have drifted apart and broken it silently.Segment array rows are assembled through
_segment_rowrather than written as bare positional literals. Nineteen of those literals stated the column order nowhere, so reorderingT_IDX..``N_IDX`` would have left every one of them silently wrong. The order is now stated once.The first row of every model in the family — rate
qiatt = 0, nothing produced yet,Diconverted against the first exponent — is built by one_initial_segment_row, shared by all five. The secant-to-nominal conversion is the part worth single-sourcing: a call site that omitted it would still produce a plausible forecast, just one wrong by a factor ofDAYS_PER_YEAR.THMreads itsD1back out of that row rather than converting a second time, so its transient boundary walk cannot disagree with the row it starts from. Verified bit-for-bit neutral by sweepingMHandTHMover their full parameter grids — including denormal and bound-adjacent values — and comparingsegment_paramsand every output function against the pre-refactor tree.The four
ParamDescdescriptors that more than one hyperbolic model declares —qi(four models), a strictly decliningDi(three), a[0, 2]-boundedbi(two), andDterm(two) — are written once onMultisegmentHyperbolicand shared. AParamDescis a validation contract, so a copy that drifted would silently widen or narrow one model’s accepted domain relative to its siblings.THMkeeps its ownqiandbi: same bounds, deliberately narrower generators, now stated as such. A test pins the sharing. Bounds, exclusions and generated values are unchanged for every model.All power-law yield math moved to a new
MultisegmentPLYieldbase class, which caches per-segment anchor conditions and gathers them withsearchsorted.PLYieldis now a subclass and supplies only its two segments; its results are bit-for-bit unchanged fort >= 0(see theNaNchange above fort < 0).The first row of
segment_paramsfor the yield models now starts at-infrather than0, so thet_startcolumn is sorted for any anchor time._lookup_segmentbinary searches that column, and a caller who disabled validation could passt0 < 0and leave it unsorted, making the search result formally undefined. Selected values are unchanged.
- Typing: the package now type-checks under
mypy --strict, and so does code that uses it petbox.dcadeclares__all__. This package shipspy.typed, so a consumer’s ownmypy --strictrun type-checks against it — and strict implies--no-implicit-reexport, under which a name merely imported into__init__.pyis not re-exported. Ordinary downstream code therefore failed:from petbox import dca model = dca.MH(1000.0, 0.8, 1.5, 0.08) error: Module "petbox.dca" does not explicitly export attribute "MH"
All 27 public names are exported, and a test asserts
__all__against the module in both directions, so adding a model without exporting it fails in the suite rather than in a user’s build.ParamDescis deliberately not exported: it is documented and reached aspetbox.dca.base.ParamDesc.Every public time and rate argument is typed
FloatLike—float | Sequence[float] | NDArray[floating] | NDArray[integer]— rather thanfloat | NDFloat. All of these already worked at runtime, since_validate_ndarrayfunnels them throughnp.atleast_1d(x).astype(np.float64), but the narrow annotation rejected them, including the list form this documentation itself uses (mh.rate([-30.0, -10.0, 0.0])). A scalar, a list, a tuple, arange, and a float or integer array of any width are now all accepted statically as well as at runtime. It stays narrower thannumpy.typing.ArrayLike, which also admits strings,None, and nested sequences — those reachastypeand either raise there or silently return a 2-d result, so excluding them keeps the call site checked. Applies torate,cum,interval_vol,monthly_vol,monthly_vol_equiv,D,beta,b,time_at_rate, the fivetransient_*functions, andgor/cgr/wor/wgr.Eight functions returned
Anythrough a declaredNDFloat, none of them wrong at runtime. Two causes:float ** floatis typedAny, because a negative base with a fractional exponent is complex, andnp.where/np.diff/np.logyielddtype[Any].SE’s four diagnostic functions now nametau ** -nas afloat, which pins the type, records why it is real (tau > 0by its bound), and hoists the scalar out of the array expression;Duong._Nfn,monthly_volandmonthly_vol_equivwrap their result innp.asarray(..., dtype=np.float64), which is a no-op rather than a copy for an array that is alreadyfloat64;THM._bfnneeded only an annotation. Values are unchanged.42 blanket
# type: ignorecomments are gone. Only one was still needed —mpmathships no stubs, now declared once as apyproject.tomloverride — and the rest were stale:scipyshipspy.typed, and thenp.putmask/np.diff/np.clipignores no longer suppressed anything.warn_unused_ignoresis on so they cannot accumulate again, and every remaining ignore names its error code.NDFloatandNDBoolare defined once inbaseand imported, rather than re-derived in each module.pyproject.tomlspells out every flag--strictimplies, somypy petbox/dcaandmypy --strict petbox/dcaagree; the list had drifted to 12 of 14. CI now runsruffandmypy --strictover the test tree as well as the package, andtests/test.shandtests/test.batmirror it.
- Typing: the package now type-checks under
- Lint: the ruff rule set is broadened, and two bugs it found are fixed
selectgainsI(import sorting),UP(pyupgrade),B(bugbear),SIM,TC,RUFandC90, and the blanketignorelist is dropped. 210 findings, almost all mechanical: PEP 585/604 annotations (Listtolist,Optional[X]toX | None), 40 unused imports, sorted import blocks. Docstring type descriptions were updated to match, so noOptional[...]orUnion[...]prose remains anywhere in the package.Bug:
petbox/dca/__init__.py’s import order was load-bearing, and sorting it broke the package outright withImportError: cannot import name 'NullAssociatedPhase' from partially initialized module.baseimportedNullPrimaryPhaseandNullAssociatedPhaseat the bottom of the file to break a cycle, which left__init__order-dependent: importing.associatedbefore.basefailed. Both imports are now local to the two methods that use them, which run only when a model is constructed, so the cycle is gone and any import order works. Nothing but the test suite caught this — mypy resolved it fine.Bug: the three monotonicity helpers in the test suite were not testing monotonicity. Each called
np.diff(arr, 6), which is the 6th-order finite difference, not the spacing — the6belonged to asignif(arr, 6)call that had been removed from the argument. The 6th difference of a geometric series stays positive, sois_monotonic_increasing(get_time())passed by luck, while a linearly-spaced array gives exactly 0 and fails. Nownp.diff(arr). No production test depended on the broken form.Two tests asserted nothing at all —
test_time_arraysbuilt a monthly grid and aTHMand checked neither;test_bourdetcalledbourdetand discarded the result, inside acatch_warningsblock it never read. Both now assert real properties. Unused-variable findings are what surfaced them.zipcalls carry an explicitstrict=. One isstrict=Falseand load-bearing:DeclineCurve.__post_init__zips againstchain(validate_params, repeat(True)), andrepeatis infinite, sostrict=Truewould raise on every construction.Three
pytest.raises(match=...)patterns used.as an accidental wildcard ("c <= 0.0") and three used it deliberately in place of brackets ("segments.0. has..."). All are now raw strings with the intent explicit.En dashes in the SPE citations became ASCII hyphens;
Valkóand the copyright signs are unchanged.The ruff
excludelist nameddocs/source/conf.py, a path that does not exist — the file isdocs/conf.py, and it was being linted all along. Removed rather than repointed: an exclude that silently matches nothing is worse than either choice.
The test directory is renamed
test/totests/.testis the name of a CPython stdlib package — the interpreter’s own regression suite,test.supportand friends — and a top-leveltest/package in the repository root shadowed it: from the repo root,import testresolved here andimport test.supportraisedModuleNotFoundError. It stays a package;__init__.pyis kept, so the relativefrom .data import ...still works. Nothing shipped either way, sincepackages.findincludes onlypetbox.dca— verified by building a wheel and confirming its only top-level entries arepetboxand the dist-info.docs/is now linted and type-checked alongside the package and the tests. The two figure-generating scripts are fully annotated and passmypy --strict; the one shape they pass around is namedComparison, and thelambda x=x: ...default-argument trick used to capture loop variables is replaced by acomparison_pairfactory, which captures by value and is inferrable. Both scripts were re-run and all ten figures indocs/imgcompared pixel by pixel against the committed versions: identical, with the same printed accuracy figures the docs quote.
- Other changes
The segment functions no longer emit
RuntimeWarning.log1preaches-infat the pole of a backward extrapolation andNaNbeyond it,_Dcheck’s denominator vanishes there,D * bis formed asinf * 0for an exponential segment carrying an infinite decline, andinf - infarises from thedtsubtraction when a terminal row sits at an unreachable time — all expected outcomes of a valid call, now wrapped innp.errstate. A terminal-decline division that overflows toinffor a denormalbis guarded the same way; thatinfplaces the terminal row at a time notcan reach, leaving it inert and the tail uncapped, which is correct and wasMH’s behaviour before. Values are unchanged.The associated-phase
D,beta, andbfunctions no longer emitRuntimeWarningatt = 0. The division by zero there is the expected limit of a power law, andbadditionally divides byDinside annp.wherethat evaluates both branches. Values are unchanged.docs/examples.rstno longer duplicatestests/doc_examples.py. The examples were maintained twice — once as the script that generates the figures, once as hand-copiedcode-blockdirectives — with nothing keeping them in step, which is how the GOR examples drifted by a factor of 1000 before. Fifteen of the seventeen blocks are nowliteralincludedirectives reading marked regions of the script; the two exceptions are%timeitoutput, which is not source. The rendered code is byte-identical to before, and a test guards the markers, since a broken one is only a Sphinx warning.docs/examples.rstgains a “Generalized and Inclining Models” section with a four-panel figure: the segmented rate against itsMHbaseline with a rate reset and a flat segment, the exponent stepping at each boundary, the pure build-up beside an incline-peak-decline forecast, and aGeneralizedPLYieldwhose second breakpoint steps the GOR. Thetime_at_rateeconomic-limit solution is marked on the rate panel.The GOR examples throughout
README.rst,docs/examples.rstanddocs/integration_validation.pynow passcinMscf/Bblas the yield models document (c=1.2for a 1200scf/BblGOR, wasc=1200.0), withmin/maxrescaled to match. The examples previously supplied anscf/Bblmagnitude while displaying outputs divided by 1000, so neither the inputs nor the printed results matched the library. All example outputs were recomputed, and a note on the unit convention was added toREADME.rst. The water-phasec=2.0is a WOR inBbl/Bbland is unchanged. The integration-accuracy figures were regenerated; the relative errors are unchanged, since the trapezoid error is scale-invariant.Regenerated every figure in
docs/imgfrom its generating script (tests/doc_examples.py,docs/bourdet_validation.py,docs/integration_validation.py). This release also adds one, the four-panel generalized-models figure described above.Fixed two malformed grid tables in
docs/numerical_integration.rstwhose rows were 1 and 2 characters narrower than their borders, in document order. One raised a docutilsERRORand failed to render as a table; the Sphinx build is now warning-free.
2.1.0
- Bug Fix
Breaking numerical change: Fix missing
gamma(1/n)factor in the Stretched Exponential (SE) closed-form cumulative and EUR.SE._Nfnreturnedqi*tau/n * P(1/n, (t/tau)^n)using the regularised lower incomplete gamma (scipygammainc= P), but the integral ofqi*exp(-(t/tau)^n)isqi*tau/n * gamma(1/n) * P(1/n, (t/tau)^n). Cumulative volume and EUR were wrong by a factor ofgamma(1/n)for anyn != 0.5— understated up to ~64% atn=0.3, exact atn=0.5, overstated ~10% forn > 0.5. Any SE forecast withn != 0.5will now report different cumulative/EUR values. For very smalln,gamma(1/n)overflows (the closed-form EUR genuinely diverges there); SE now falls back to the bounded numerical integrator.
- Performance
Expose
n_gridin the numerical integrator viacum(t, n_grid=...)(and the other volume methods). Default 10,000 is unchanged; a smaller value (e.g. 2,000, ~5e-5 relative error) trades accuracy for a proportional speed-up on the numerically-integrated path (PLE, associated yields).n_grid < 2raisesValueError.
- Other changes
Remove unreachable
pass(and commented-out lines) after araiseinTHM._validate; flagged as unreachable bymypy. No behaviour change.Add
test_SE_cum_matches_integralandtest_PLE_cum_matches_integral, which comparecumagainst adaptive quadrature of the rate — the previouscheck_modelassertions only verified finiteness, so a constant-factor error in a closed-form cumulative passed silently.
2.0.0
- Breaking: Minimum dependency versions raised
Require
numpy >= 2.1(was>= 1.21.1)Require
scipy >= 1.13(was>= 1.7.1)Require
Python >= 3.10(was>= 3.7)ParamDesc.naive_gentype changed fromRandomStatetoGenerator
- Deprecation fixes
Replace
np.bool_withboolin dtype specificationMigrate
numpy.random.RandomStatetonumpy.random.Generator
1.3.0
- Performance
Vectorize
_integrate_withusingcumulative_trapezoidon a log-spaced grid — eliminates per-interval Python loop (~3x faster, comparable accuracy)Vectorize
bourdet()usingsearchsorted— eliminates per-point Python helper calls
- Bug Fix
Fix
bourdet()producingNaNat right-edge points when smoothing window exceeds array boundaryFix
bourdet()incorrect backward-difference at right edge (_get_R_derselected pointiitself, causing division by zero)
- Numerical stability
Replace
np.log(1 + x)withnp.log1p(x)in_qcheckand_Ncheckfor small decline ratesReplace
np.log(1 + D*b)withlog1p(D*b)insecant_from_nominalUse
np.expm1fort^(1-m) - 1in Duong model for precision neart=1
1.2.0
- Build system
Migrate from
setup.py/setup.cfgtopyproject.tomlReplace
flake8withruffVersion now single-sourced in
pyproject.toml, resolved at runtime viaimportlib.metadata
- Bug Fix
Fix overflow in
MultisegmentHyperbolic._Ncheckwhen decline rate is near-zero (subnormal), causingcum(0)to returnNaN
- Other changes
Fix
mypystrict-mode type errors inbourdet.pyandbase.pyUpdate CI and test scripts to use
ruff
1.1.0
- Bug Fix
Fix bug in sign in
MultisegmentHyperbolic.secant_from_nominal
- Other changes
Add mpmath to handle precision requires of THM transient functions (only required to use the functions)
Adjust default degree of THM transient function quadrature integration from 50 to 10 (scipy default is 5)
Update package versions for docs and builds
Address various floating point errors, suppress numpy warnings for those which are mostly unavoidable
Add test/doc_exapmles.py and update figures (not sure what happened to the old file)
Adjust range of values in tests to avoid numerical errors in numpy and scipy functions… these were near-epsilon impractical values anyway
1.0.8
- New functions
Added
WaterPhase.wgrmethod
- Other changes
Adjust yield model rate function to return consistent units if primary phase is oil or gas
Update to numpy v1.20 typing
1.0.7
- Allow disabling of parameter checks by passing an interable of booleans, each indicating a check
to each model parameter.
Explicitly handle floating point overflow errors rather than relying on numpy.
1.0.6
- New functions
Added
WaterPhaseclassAdded
WaterPhase.wormethodAdded
PrimaryPhase.add_watermethod
- Other changes
A
yieldmodel may inherit bothSecondaryPhaseandWaterPhase, with the respective methods removed upon attachment to aPrimaryPhase.
1.0.5
- New functions
Bourdet algorithm
- Other changes
Update docstrings
Add bourdet data derivatives to detailed use examples
1.0.4
Fix typos in docs
1.0.3
Add documentation
Genericize numerical integration
Various refactoring
0.0.1 - 1.0.2
Internal releases